diff --git a/dashboard/src/routes/resource/knowledge-base.tsx b/dashboard/src/routes/resource/knowledge-base.tsx
index a4accd75e9..85ac50fc6b 100644
--- a/dashboard/src/routes/resource/knowledge-base.tsx
+++ b/dashboard/src/routes/resource/knowledge-base.tsx
@@ -1,9 +1,7 @@
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import {
- ChevronLeft,
- ChevronRight,
Database,
Gauge,
Loader2,
@@ -12,7 +10,6 @@ import {
Save,
SlidersHorizontal,
Sparkles,
- Trash2,
Upload,
CheckCircle2,
CircleAlert,
@@ -23,11 +20,10 @@ import {
import { CodeEditor } from '@/components/CodeEditor'
import { MemoryDeleteDialog } from '@/components/memory/MemoryDeleteDialog'
import { MemoryConfigEditor } from '@/components/memory/MemoryConfigEditor'
+import { MemoryMiniTabs } from '@/components/memory/MemoryMiniTabs'
import { Alert, AlertDescription } from '@/components/ui/alert'
-import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
-import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
@@ -36,21 +32,11 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
-import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
-import { Progress } from '@/components/ui/progress'
-import { ScrollArea } from '@/components/ui/scroll-area'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select'
-import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { Tabs, TabsContent } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/hooks/use-toast'
+import { memoryProgressClient, type MemoryProgressEvent } from '@/lib/memory-progress-client'
import { cn } from '@/lib/utils'
import {
cancelMemoryImportTask,
@@ -105,536 +91,28 @@ import {
type MemoryTaskPayload,
} from '@/lib/memory-api'
-const DELETE_OPERATION_FETCH_LIMIT = 100
-const DELETE_OPERATION_PAGE_SIZE = 6
-const DELETE_OPERATION_ITEM_PAGE_SIZE = 8
-const FEEDBACK_CORRECTION_FETCH_LIMIT = 100
-const FEEDBACK_CORRECTION_PAGE_SIZE = 6
-const FEEDBACK_ACTION_LOG_PAGE_SIZE = 8
-const IMPORT_CHUNK_PAGE_SIZE = 50
-
-const RUNNING_IMPORT_STATUS = new Set(['preparing', 'running', 'cancel_requested'])
-const QUEUED_IMPORT_STATUS = new Set(['queued'])
-
-const IMPORT_STATUS_TEXT: Record
= {
- queued: '排队中',
- preparing: '准备中',
- running: '运行中',
- cancel_requested: '取消中',
- cancelled: '已取消',
- completed: '已完成',
- completed_with_errors: '完成(有错误)',
- failed: '失败',
-}
-
-const IMPORT_STEP_TEXT: Record = {
- queued: '排队中',
- preparing: '准备中',
- running: '运行中',
- splitting: '分块中',
- extracting: '抽取中',
- writing: '写入中',
- saving: '保存中',
- backfilling: '回填中',
- converting: '转换中',
- verifying: '校验中',
- switching: '切换中',
- cancel_requested: '取消中',
- cancelled: '已取消',
- completed: '已完成',
- completed_with_errors: '完成(有错误)',
- failed: '失败',
-}
-
-const IMPORT_KIND_OPTIONS: Array<{ value: MemoryImportTaskKind; label: string; description: string }> = [
- { value: 'upload', label: '上传文件', description: '从本地批量上传资料文件' },
- { value: 'paste', label: '粘贴导入', description: '直接粘贴文本或 JSON 内容创建任务' },
- { value: 'raw_scan', label: '本地扫描', description: '按路径别名和匹配规则批量扫描导入' },
- { value: 'lpmm_openie', label: 'LPMM OpenIE', description: '读取 LPMM 数据并抽取关系' },
- { value: 'lpmm_convert', label: 'LPMM 转换', description: '将 LPMM 数据转换到目标目录' },
- { value: 'temporal_backfill', label: '时序回填', description: '为已有数据补充时间字段' },
- { value: 'maibot_migration', label: 'MaiBot 迁移', description: '从 MaiBot 历史数据迁移长期记忆' },
-]
-
-function normalizeProgress(value: number | string | null | undefined): number {
- const numeric = Number(value ?? 0)
- if (!Number.isFinite(numeric)) {
- return 0
- }
- if (numeric < 0) {
- return 0
- }
- if (numeric > 100) {
- return 100
- }
- return numeric
-}
-
-function parseOptionalPositiveInt(input: string): number | undefined {
- const value = input.trim()
- if (!value) {
- return undefined
- }
- const parsed = Number(value)
- if (!Number.isInteger(parsed) || parsed <= 0) {
- return undefined
- }
- return parsed
-}
-
-function parseCommaSeparatedList(input: string): string[] {
- return input
- .split(',')
- .map((item) => item.trim())
- .filter(Boolean)
-}
-
-function normalizeImportInputMode(value: string): MemoryImportInputMode {
- return value === 'json' ? 'json' : 'text'
-}
-
-function getImportStatusLabel(status: string): string {
- const normalized = String(status ?? '').trim()
- if (!normalized) {
- return '-'
- }
- return IMPORT_STATUS_TEXT[normalized] ?? normalized
-}
-
-function getImportStepLabel(step: string): string {
- const normalized = String(step ?? '').trim()
- if (!normalized) {
- return '-'
- }
- return IMPORT_STEP_TEXT[normalized] ?? normalized
-}
-
-function getImportStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
- if (status === 'failed') {
- return 'destructive'
- }
- if (status === 'completed') {
- return 'default'
- }
- if (status === 'completed_with_errors' || status === 'cancelled') {
- return 'secondary'
- }
- if (RUNNING_IMPORT_STATUS.has(status) || QUEUED_IMPORT_STATUS.has(status)) {
- return 'outline'
- }
- return 'secondary'
-}
-
-function formatImportTime(timestamp?: number | null): string {
- if (!timestamp) {
- return '-'
- }
- const normalized = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000
- const value = new Date(normalized)
- if (Number.isNaN(value.getTime())) {
- return '-'
- }
- return value.toLocaleString('zh-CN', {
- hour12: false,
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- })
-}
-
-function formatDeleteOperationMode(mode: string): string {
- switch (mode) {
- case 'entity':
- return '实体'
- case 'relation':
- return '关系'
- case 'paragraph':
- return '段落'
- case 'source':
- return '来源'
- case 'mixed':
- return '混合'
- default:
- return mode || '未知'
- }
-}
-
-function formatDeleteOperationStatus(status: string): string {
- switch (status) {
- case 'executed':
- return '已执行'
- case 'restored':
- return '已恢复'
- default:
- return status || '未知'
- }
-}
-
-function formatDeleteOperationTime(timestamp?: number | null): string {
- if (!timestamp) {
- return '未知时间'
- }
- const normalized = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000
- const value = new Date(normalized)
- if (Number.isNaN(value.getTime())) {
- return '未知时间'
- }
- return value.toLocaleString('zh-CN', {
- hour12: false,
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- })
-}
-
-function formatFeedbackDecision(decision: string): string {
- switch (decision) {
- case 'correct':
- return '纠正'
- case 'reject':
- return '否定'
- case 'confirm':
- return '确认'
- case 'supplement':
- return '补充'
- case 'none':
- return '无动作'
- default:
- return decision || '未知'
- }
-}
-
-function formatFeedbackTaskStatus(status: string): string {
- switch (status) {
- case 'pending':
- return '待处理'
- case 'running':
- return '处理中'
- case 'applied':
- return '已应用'
- case 'skipped':
- return '已跳过'
- case 'error':
- return '失败'
- default:
- return status || '未知'
- }
-}
-
-function formatFeedbackRollbackStatus(status: string): string {
- switch (status) {
- case 'none':
- return '未回退'
- case 'running':
- return '回退中'
- case 'rolled_back':
- return '已回退'
- case 'error':
- return '回退失败'
- default:
- return status || '未知'
- }
-}
-
-function getFeedbackStatusVariant(
- status: string,
-): 'default' | 'secondary' | 'destructive' | 'outline' {
- if (status === 'applied' || status === 'rolled_back') {
- return 'default'
- }
- if (status === 'error') {
- return 'destructive'
- }
- if (status === 'running' || status === 'pending') {
- return 'outline'
- }
- return 'secondary'
-}
-
-function summarizeFeedbackActionPayload(value: Record | undefined): string {
- if (!value) {
- return ''
- }
- const hash = String(value.hash ?? '').trim()
- const subject = String(value.subject ?? '').trim()
- const predicate = String(value.predicate ?? '').trim()
- const object = String(value.object ?? '').trim()
- if (subject && predicate && object) {
- return formatDeleteRelationText(subject, predicate, object)
- }
- if (hash) {
- return hash
- }
- if (Array.isArray(value.target_hashes) && value.target_hashes.length > 0) {
- return `targets ${value.target_hashes.length}`
- }
- return trimDeleteItemText(JSON.stringify(value, null, 2), 120)
-}
-
-function pickFeedbackRelationTriplet(value: unknown): Record | null {
- if (!value || typeof value !== 'object') {
- return null
- }
- const record = value as Record
- const subject = String(record.subject ?? '').trim()
- const predicate = String(record.predicate ?? '').trim()
- const object = String(record.object ?? '').trim()
- if (!subject || !predicate || !object) {
- return null
- }
- return record
-}
-
-function formatFeedbackRelationTriplet(value: unknown): string {
- const triplet = pickFeedbackRelationTriplet(value)
- if (!triplet) {
- return ''
- }
- return formatDeleteRelationText(
- String(triplet.subject ?? ''),
- String(triplet.predicate ?? ''),
- String(triplet.object ?? ''),
- )
-}
-
-function getFeedbackCorrectionPreview(task: MemoryFeedbackCorrectionDetailTaskPayload | MemoryFeedbackCorrectionSummaryPayload | null): {
- headline: string
- oldRelation: string
- newRelation: string
-} {
- if (!task) {
- return {
- headline: '当前没有纠错摘要',
- oldRelation: '',
- newRelation: '',
- }
- }
-
- const detailTask = task as MemoryFeedbackCorrectionDetailTaskPayload
- const rollbackPlanSummary = detailTask.rollback_plan_summary ?? {}
- const forgottenRelations = Array.isArray(rollbackPlanSummary.forgotten_relations)
- ? rollbackPlanSummary.forgotten_relations
- : []
- const correctedWrite = rollbackPlanSummary.corrected_write && typeof rollbackPlanSummary.corrected_write === 'object'
- ? rollbackPlanSummary.corrected_write
- : {}
- const correctedRelations = Array.isArray((correctedWrite as Record).corrected_relations)
- ? ((correctedWrite as Record).corrected_relations as unknown[])
- : []
-
- const oldRelation = formatFeedbackRelationTriplet(forgottenRelations[0])
- const newRelation = formatFeedbackRelationTriplet(correctedRelations[0])
-
- if (oldRelation && newRelation) {
- return {
- headline: `将“${oldRelation}”纠正为“${newRelation}”`,
- oldRelation,
- newRelation,
- }
- }
- if (newRelation) {
- return {
- headline: `补充了新的纠错结论:“${newRelation}”`,
- oldRelation: '',
- newRelation,
- }
- }
- if (oldRelation) {
- return {
- headline: `撤销了旧记忆关系:“${oldRelation}”`,
- oldRelation,
- newRelation: '',
- }
- }
- return {
- headline: task.query_text || '当前纠错没有可读摘要',
- oldRelation: '',
- newRelation: '',
- }
-}
-
-function buildFeedbackImpactSummary(task: MemoryFeedbackCorrectionDetailTaskPayload | MemoryFeedbackCorrectionSummaryPayload | null): string[] {
- if (!task) {
- return []
- }
-
- const counts = task.affected_counts ?? {}
- const items: string[] = []
- if (Number(counts.relations ?? 0) > 0) {
- items.push(`影响关系 ${Number(counts.relations ?? 0)} 条`)
- }
- if (Number(counts.corrected_relations ?? 0) > 0) {
- items.push(`新增纠正关系 ${Number(counts.corrected_relations ?? 0)} 条`)
- }
- if (Number(counts.correction_paragraphs ?? 0) > 0) {
- items.push(`写入纠错段落 ${Number(counts.correction_paragraphs ?? 0)} 条`)
- }
- if (Number(counts.stale_paragraphs ?? 0) > 0) {
- items.push(`标记旧段落 ${Number(counts.stale_paragraphs ?? 0)} 条`)
- }
- if (Number(counts.episode_sources ?? 0) > 0) {
- items.push(`触发 Episode 修复 ${Number(counts.episode_sources ?? 0)} 个来源`)
- }
- if (Number(counts.profile_person_ids ?? 0) > 0) {
- items.push(`触发 Profile 刷新 ${Number(counts.profile_person_ids ?? 0)} 个对象`)
- }
- return items
-}
-
-function formatFeedbackActionType(actionType: string): string {
- switch (actionType) {
- case 'classification':
- return '判定纠错'
- case 'forget_relation':
- return '撤销旧关系'
- case 'mark_stale_paragraph':
- return '标记旧段落'
- case 'write_correction':
- return '写入纠错'
- case 'rollback_restore_relation':
- return '恢复旧关系'
- case 'rollback_delete_correction_paragraph':
- return '隐藏纠错段落'
- case 'rollback_revert_corrected_relation':
- return '撤销纠正关系'
- case 'rollback_clear_stale_mark':
- return '清除脏段落标记'
- case 'rollback_enqueue_episode_rebuild':
- return '加入 Episode 修复队列'
- case 'rollback_enqueue_profile_refresh':
- return '加入 Profile 刷新队列'
- case 'rollback_error':
- return '回退失败'
- case 'error':
- return '处理失败'
- case 'skip':
- return '跳过处理'
- default:
- return actionType || '未知动作'
- }
-}
-
-function describeFeedbackActionLog(item: MemoryFeedbackActionLogPayload): string {
- const beforeSummary = summarizeFeedbackActionPayload(item.before_payload)
- const afterSummary = summarizeFeedbackActionPayload(item.after_payload)
-
- switch (item.action_type) {
- case 'classification':
- return afterSummary ? `系统完成判定:${afterSummary}` : '系统完成纠错判定'
- case 'forget_relation':
- return beforeSummary ? `旧关系已失效:${beforeSummary}` : '旧关系已被标记为失效'
- case 'mark_stale_paragraph':
- return '旧段落已标记为待复核,后续检索会更谨慎地使用它'
- case 'write_correction':
- return afterSummary ? `已写入新的纠错结果:${afterSummary}` : '已写入新的纠错段落和关系'
- case 'rollback_restore_relation':
- return afterSummary ? `已恢复旧关系状态:${afterSummary}` : '已恢复旧关系状态'
- case 'rollback_delete_correction_paragraph':
- return '已隐藏这次纠错写入的段落'
- case 'rollback_revert_corrected_relation':
- return '已撤销纠错阶段新增的关系'
- case 'rollback_clear_stale_mark':
- return '已清除旧段落的待复核标记'
- case 'rollback_enqueue_episode_rebuild':
- return '已重新加入 Episode 修复队列'
- case 'rollback_enqueue_profile_refresh':
- return '已重新加入 Profile 刷新队列'
- case 'rollback_error':
- return item.reason || '这次回退执行失败'
- case 'error':
- return item.reason || '这次纠错处理失败'
- case 'skip':
- return item.reason || '这次纠错被跳过'
- default:
- return afterSummary || beforeSummary || item.reason || '记录了一条动作日志'
- }
-}
-
-type DeleteOperationItem = NonNullable[number]
-
-function trimDeleteItemText(value: string, maxLength: number = 140): string {
- const normalized = String(value ?? '').trim().replace(/\s+/g, ' ')
- if (!normalized) {
- return ''
- }
- if (normalized.length <= maxLength) {
- return normalized
- }
- return `${normalized.slice(0, maxLength)}...`
-}
-
-function formatDeleteRelationText(subject: string, predicate: string, object: string): string {
- const left = String(subject ?? '').trim()
- const middle = String(predicate ?? '').trim()
- const right = String(object ?? '').trim()
- return [left, middle, right].filter(Boolean).join(' -> ')
-}
-
-function getDeleteOperationItemLabel(item: DeleteOperationItem): string {
- const payload = item.payload ?? {}
- if (item.item_type === 'entity') {
- const entity = (payload.entity ?? {}) as Record
- return String(entity.name ?? item.item_key ?? item.item_hash ?? '未命名实体')
- }
- if (item.item_type === 'relation') {
- const relation = (payload.relation ?? {}) as Record
- return (
- formatDeleteRelationText(
- String(relation.subject ?? ''),
- String(relation.predicate ?? ''),
- String(relation.object ?? ''),
- ) || String(item.item_key ?? item.item_hash ?? '未命名关系')
- )
- }
- if (item.item_type === 'paragraph') {
- const paragraph = (payload.paragraph ?? {}) as Record
- const source = String(paragraph.source ?? '').trim()
- return source || String(item.item_key ?? item.item_hash ?? '未命名段落')
- }
- return String(item.item_key ?? item.item_hash ?? '未命名对象')
-}
-
-function getDeleteOperationItemPreview(item: DeleteOperationItem): string {
- const payload = item.payload ?? {}
- if (item.item_type === 'entity') {
- const paragraphLinks = Array.isArray(payload.paragraph_links) ? payload.paragraph_links : []
- if (paragraphLinks.length > 0) {
- return `关联段落 ${paragraphLinks.length} 个`
- }
- return '实体快照'
- }
- if (item.item_type === 'relation') {
- const relation = (payload.relation ?? {}) as Record
- const paragraphHashes = Array.isArray(payload.paragraph_hashes) ? payload.paragraph_hashes : []
- const confidence = relation.confidence
- const parts = []
- if (paragraphHashes.length > 0) {
- parts.push(`证据段落 ${paragraphHashes.length} 个`)
- }
- if (typeof confidence === 'number') {
- parts.push(`置信度 ${confidence.toFixed(2)}`)
- }
- return parts.join(',') || '关系快照'
- }
- if (item.item_type === 'paragraph') {
- const paragraph = (payload.paragraph ?? {}) as Record
- return trimDeleteItemText(String(paragraph.content ?? ''))
- }
- return ''
-}
-
-function getDeleteOperationItemSource(item: DeleteOperationItem): string {
- const payload = item.payload ?? {}
- if (item.item_type === 'paragraph') {
- const paragraph = (payload.paragraph ?? {}) as Record
- return String(paragraph.source ?? '').trim()
- }
- return String(payload.source ?? '').trim()
-}
+import {
+ DELETE_OPERATION_FETCH_LIMIT,
+ DELETE_OPERATION_ITEM_PAGE_SIZE,
+ DELETE_OPERATION_PAGE_SIZE,
+ FEEDBACK_ACTION_LOG_PAGE_SIZE,
+ FEEDBACK_CORRECTION_FETCH_LIMIT,
+ FEEDBACK_CORRECTION_PAGE_SIZE,
+ IMPORT_CHUNK_PAGE_SIZE,
+ QUEUED_IMPORT_STATUS,
+ RUNNING_IMPORT_STATUS,
+} from './knowledge-base/constants'
+import {
+ buildFeedbackImpactSummary,
+ getFeedbackCorrectionPreview,
+ parseCommaSeparatedList,
+ parseOptionalPositiveInt,
+ summarizeFeedbackActionPayload,
+} from './knowledge-base/utils'
+import { DeleteTab } from './knowledge-base/tabs/DeleteTab'
+import { FeedbackTab } from './knowledge-base/tabs/FeedbackTab'
+import { ImportTab } from './knowledge-base/tabs/ImportTab'
+import { TuningTab } from './knowledge-base/tabs/TuningTab'
export function KnowledgeBasePage() {
const navigate = useNavigate()
@@ -645,6 +123,9 @@ export function KnowledgeBasePage() {
const [creatingImport, setCreatingImport] = useState(false)
const [creatingTuning, setCreatingTuning] = useState(false)
const [rawMode, setRawMode] = useState(false)
+ const [activeTab, setActiveTab] = useState<
+ 'overview' | 'config' | 'import' | 'tuning' | 'delete' | 'feedback'
+ >('overview')
const [schemaPayload, setSchemaPayload] = useState(null)
const [visualConfig, setVisualConfig] = useState>({})
@@ -1593,6 +1074,44 @@ export function KnowledgeBasePage() {
}
}, [importAutoPolling, importPollInterval, loadImportTaskDetail, refreshImportQueue, selectedImportTaskId])
+ // 统一 WebSocket 推送:作为轮询的实时增强;后端未广播时由轮询兜底
+ const selectedImportTaskIdRef = useRef('')
+ useEffect(() => {
+ selectedImportTaskIdRef.current = selectedImportTaskId
+ }, [selectedImportTaskId])
+
+ useEffect(() => {
+ let cancelled = false
+ let unsubscribe: (() => Promise) | undefined
+ const handleEvent = (event: MemoryProgressEvent) => {
+ if (event.topic === 'import_progress') {
+ void refreshImportQueue(true)
+ if (selectedImportTaskIdRef.current) {
+ void loadImportTaskDetail(selectedImportTaskIdRef.current, true)
+ }
+ }
+ }
+ void memoryProgressClient
+ .subscribe(handleEvent, ['import_progress'])
+ .then((cleanup) => {
+ if (cancelled) {
+ void cleanup()
+ return
+ }
+ unsubscribe = cleanup
+ })
+ .catch((error) => {
+ // 订阅失败不影响轮询兜底
+ console.warn('订阅长期记忆 WebSocket 失败,已退化到轮询兜底', error)
+ })
+ return () => {
+ cancelled = true
+ if (unsubscribe) {
+ void unsubscribe()
+ }
+ }
+ }, [loadImportTaskDetail, refreshImportQueue])
+
const filteredSources = useMemo(() => {
const keyword = sourceSearch.trim().toLowerCase()
if (!keyword) {
@@ -2266,22 +1785,25 @@ export function KnowledgeBasePage() {
}
return (
-
-
+
+
-
长期记忆控制台
+
+ A_Memorix
+
+
长期记忆控制台
- A_Memorix 的配置、自检、导入和检索调优,都在这里!
+ 在这里完成配置、自检、导入资料和检索调优——一站式管理记忆库
-
-
-
-
- {runtimeBadges.map((item) => (
-
-
-
-
- {item.label}
- {item.value}
-
-
+
+
+ {/* 运行时状态条 —— 紧凑、常驻、一眼看完 */}
+ {runtimeBadges.length > 0 ? (
+
+
+
+
+ 运行时状态
+
+
void refreshSelfCheck()}
+ disabled={refreshingCheck}
+ >
+
+ 自检
+
+
+
+ {runtimeBadges.map((item) => (
+
+
+
+
{item.label}
+
+ {item.value}
+
+
+ {item.description}
+
+
-
{item.description}
-
-
- ))}
+ ))}
+
+
+ ) : null}
+
+ {/* 快速开始 Hero —— 给新用户明确的"先做什么" */}
+
+
+
+
+ 快速开始
+
+
先从这三件事入手
+
+ 不知道该做什么?挑一个最常用的入口,下面的标签页里有更详细的设置。
+
+
+
+
setActiveTab('import')}
+ className="group flex items-start gap-3 rounded-xl border border-border/70 bg-background/80 p-3.5 text-left transition hover:border-primary/50 hover:bg-background hover:shadow-md"
+ >
+
+
+
+
+
导入资料
+
+ 把文件、聊天记录写进记忆库
+
+
+
+
setActiveTab('tuning')}
+ className="group flex items-start gap-3 rounded-xl border border-border/70 bg-background/80 p-3.5 text-left transition hover:border-primary/50 hover:bg-background hover:shadow-md"
+ >
+
+
+
+
+
检索调优
+
+ 让回忆变得更准、更聪明
+
+
+
+
navigate({ to: '/resource/knowledge-graph' })}
+ className="group flex items-start gap-3 rounded-xl border border-border/70 bg-background/80 p-3.5 text-left transition hover:border-primary/50 hover:bg-background hover:shadow-md"
+ >
+
+
+
+
+
打开图谱
+
+ 可视化已存的实体和关系
+
+
+
+
+
-
-
-
- 概览
-
-
- 配置
-
-
- 导入
-
-
- 调优
-
-
- 删除
-
-
- 纠错历史
-
-
+ setActiveTab(value as typeof activeTab)}
+ className="space-y-5"
+ >
+
+
+
@@ -2367,19 +1975,11 @@ export function KnowledgeBasePage() {
- 当前运行态摘要
+ 关键指标
- 这里展示运行态重点指标,方便先判断是否需要导入或调优
+ 用于快速判断是否需要补回向量或重新调优
-
-
- {runtimeConfig?.runtime_ready ? '运行就绪' : '运行未就绪'}
-
-
- {runtimeConfig?.embedding_degraded ? 'Embedding 已退化' : 'Embedding 正常'}
-
-
待补回段落向量
@@ -2390,12 +1990,12 @@ export function KnowledgeBasePage() {
{runtimeConfig?.paragraph_vector_backfill_failed ?? 0}
-
-
当前调优配置
-
+
+ 当前调优配置
+
{JSON.stringify(tuningProfile, null, 2)}
-
+
@@ -2467,1871 +2067,231 @@ export function KnowledgeBasePage() {
-
-
-
-
-
-
-
- 创建导入任务
-
- 按“选择导入方式 → 检查公共参数 → 创建任务”的顺序完成导入。
-
-
- setImportCreateMode(value as MemoryImportTaskKind)}
- className="space-y-4"
- >
-
-
-
- {IMPORT_KIND_OPTIONS.map((item) => (
-
- {item.label}
-
- ))}
-
-
-
-
-
-
公共参数
-
这些设置会应用到当前导入任务。一般保持默认即可,只在批量导入或排查问题时调整。
-
-
-
-
-
同时处理多少个文件;文件很多时再适当调高。
-
setImportCommonFileConcurrency(event.target.value)}
- />
-
-
-
-
单个文件内并行处理多少个分块;过高会增加资源占用。
-
setImportCommonChunkConcurrency(event.target.value)}
- />
-
-
-
- setImportCommonLlmEnabled(Boolean(value))}
- />
- 启用 LLM 抽取
-
-
需要模型参与抽取,质量更高但耗时更长。
-
-
-
- setImportCommonChatLog(Boolean(value))}
- />
- 按聊天日志解析
-
-
适合导入聊天记录,会尽量保留时间和对话上下文。
-
-
-
-
-
- 高级参数(通常不用修改)
-
-
-
-
- setImportCommonStrategyOverride(event.target.value)}
- />
-
-
-
- setImportCommonDedupePolicy(event.target.value)}
- />
-
-
-
- setImportCommonChatReferenceTime(event.target.value)}
- />
-
-
- setImportCommonForce(Boolean(value))}
- />
- 强制导入
-
-
- setImportCommonClearManifest(Boolean(value))}
- />
- 清空导入清单
-
-
-
-
-
-
-
-
选择一个或多个本地文件创建导入任务,适合批量导入资料或聊天记录。
-
-
-
-
-
-
-
- setUploadFiles(Array.from(event.target.files ?? []))}
- />
-
-
-
已选择 {uploadFiles.length} 个文件
-
-
-
-
-
-
直接粘贴少量文本或 JSON,适合临时补充一段资料。
-
-
-
- setPasteName(event.target.value)} />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
扫描目录文件,适合本地批处理
-
-
-
- setRawAlias(event.target.value)} />
-
-
-
-
-
-
-
- setRawRelativePath(event.target.value)} />
-
-
-
- setRawGlob(event.target.value)} />
-
-
-
- setRawRecursive(Boolean(value))} />
- 递归扫描
-
-
-
-
-
-
-
读取 LPMM 内容并抽取关系
-
-
- setOpenieIncludeAllJson(Boolean(value))}
- />
- 包含全部 JSON 文件
-
-
-
-
-
-
-
-
-
-
-
为已有数据补齐时间字段
-
-
-
- setBackfillDryRun(Boolean(value))} />
- 只预演,不写入数据
-
-
- setBackfillNoCreatedFallback(Boolean(value))}
- />
- 禁用创建时间回退
-
-
-
-
-
-
-
-
迁移 MaiBot 历史长期记忆
-
-
-
- setMaibotNoResume(Boolean(value))} />
- 从头开始,不继续上次进度
-
-
- setMaibotResetState(Boolean(value))} />
- 重置迁移状态
-
-
- setMaibotDryRun(Boolean(value))} />
- 只预演,不写入数据
-
-
- setMaibotVerifyOnly(Boolean(value))} />
- 仅校验
-
-
-
-
-
-
-
- void submitImportByMode()} disabled={creatingImport}>
- {creatingImport ? : }
- 创建导入任务
-
-
-
-
-
-
- 路径预检
- 在创建本地扫描、转换或迁移任务前,先确认路径会被解析到哪里。
-
-
-
-
-
-
选择后端允许访问的数据根目录。
-
-
-
-
-
填写相对于路径别名的子路径,不需要填写完整磁盘路径。
-
setPathResolveRelativePath(event.target.value)}
- placeholder="例如 exports/weekly"
- />
-
-
-
- setPathResolveMustExist(Boolean(value))} />
- 要求路径已存在
-
- void resolveImportPath()}
- disabled={resolvingPath || !pathResolveAlias.trim()}
- >
- {resolvingPath ? : }
- 解析路径
-
-
-
-
-
-
-
-
-
-
- 导入队列
- void refreshImportQueue()}>
-
- 刷新
-
-
-
-
- 查看任务是否正在运行、排队等待或已经结束。点击任务卡片可查看详情。
-
-
- 运行中 {runningImportTasks.length}
- 排队中 {queuedImportTasks.length}
- 最近完成 {recentImportTasks.length}
-
-
-
-
-
- {importErrorText ? (
-
- {importErrorText}
-
- ) : null}
-
-
-
-
运行中
-
{runningImportTasks.length}
-
- {runningImportTasks.length > 0 ? (
-
-
- {runningImportTasks.map((task) => {
- const isSelected = task.task_id === selectedImportTaskId
- return (
-
void selectImportTask(task.task_id)}
- className={cn(
- 'w-full rounded-xl border p-4 text-left transition-all',
- isSelected
- ? 'border-primary/70 bg-primary/5 shadow-sm'
- : 'bg-background/80 hover:border-muted-foreground/40 hover:bg-muted/20',
- )}
- >
-
-
-
- {task.task_id}
-
-
{String(task.task_kind ?? task.mode ?? '-')}
-
-
- {getImportStatusLabel(String(task.status ?? ''))}
-
-
-
- {getImportStepLabel(String(task.current_step ?? 'running'))}
- {Number(task.progress ?? 0).toFixed(1)}%
-
-
-
- )
- })}
-
-
- ) : (
-
当前没有运行中任务
- )}
-
-
-
-
-
排队中
-
{queuedImportTasks.length}
-
- {queuedImportTasks.length > 0 ? (
-
-
- {queuedImportTasks.map((task) => {
- const isSelected = task.task_id === selectedImportTaskId
- return (
-
void selectImportTask(task.task_id)}
- className={cn(
- 'w-full rounded-xl border p-4 text-left transition-all',
- isSelected
- ? 'border-primary/70 bg-primary/5 shadow-sm'
- : 'bg-background/80 hover:border-muted-foreground/40 hover:bg-muted/20',
- )}
- >
-
-
-
- {task.task_id}
-
-
{String(task.task_kind ?? task.mode ?? '-')}
-
-
- {getImportStatusLabel(String(task.status ?? ''))}
-
-
-
- 创建时间
- {formatImportTime(task.created_at)}
-
-
- )
- })}
-
-
- ) : (
-
当前没有排队任务
- )}
-
-
-
-
-
最近完成
-
{recentImportTasks.length}
-
- {recentImportTasks.length > 0 ? (
-
-
- {recentImportTasks.map((task) => {
- const isSelected = task.task_id === selectedImportTaskId
- return (
-
void selectImportTask(task.task_id)}
- className={cn(
- 'w-full rounded-xl border p-4 text-left transition-all',
- isSelected
- ? 'border-primary/70 bg-primary/5 shadow-sm'
- : 'bg-background/80 hover:border-muted-foreground/40 hover:bg-muted/20',
- )}
- >
-
-
-
- {task.task_id}
-
-
{String(task.task_kind ?? task.mode ?? '-')}
-
-
- {getImportStatusLabel(String(task.status ?? ''))}
-
-
-
- 完成进度
- {Number(task.progress ?? 0).toFixed(1)}%
-
-
-
- )
- })}
-
-
- ) : (
-
暂时没有历史任务
- )}
-
-
-
-
-
-
-
-
-
-
任务详情
-
- void cancelSelectedImportTask()}
- disabled={!selectedImportTaskId}
- >
- 取消任务
-
- void retrySelectedImportTask()}
- disabled={!selectedImportTaskId}
- >
- 重试失败项
-
-
-
- 支持文件级和分块级状态观察,可直接在当前页面定位失败原因
-
-
- {selectedImportTaskLoading ? (
-
-
- 正在加载任务详情...
-
- ) : null}
-
- {!selectedImportTaskResolved ? (
-
- 请选择一个导入任务查看详情
-
- ) : (
- <>
-
-
任务摘要
-
-
-
-
- 任务 ID
-
- {selectedImportTaskResolved.task_id}
-
-
-
- 任务类型
- {String(selectedImportTaskResolved.task_kind ?? selectedImportTaskResolved.mode ?? '-')}
-
-
- 状态 / 步骤
-
-
-
- {getImportStatusLabel(String(selectedImportTaskResolved.status ?? ''))}
-
-
- {getImportStepLabel(String(selectedImportTaskResolved.current_step ?? ''))}
-
-
-
-
-
- 进度
-
-
-
- {Number(selectedImportTaskResolved.progress ?? 0).toFixed(1)}% · 块
- {' '}
- {Number(selectedImportTaskResolved.done_chunks ?? 0)}
- {' / '}
- {Number(selectedImportTaskResolved.total_chunks ?? 0)}
-
-
-
-
-
-
- 创建时间
- {formatImportTime(selectedImportTaskResolved.created_at)}
-
-
- 更新时间
- {formatImportTime(selectedImportTaskResolved.updated_at)}
-
-
-
-
-
-
- {selectedImportRetrySummary ? (
-
-
重试摘要
-
-
-
-
- 按分块重试的文件数
- {Number(selectedImportRetrySummary.chunk_retry_files ?? 0)}
-
-
- 按分块重试的分块数
- {Number(selectedImportRetrySummary.chunk_retry_chunks ?? 0)}
-
-
- 回退整文件重试数
- {Number(selectedImportRetrySummary.file_fallback_files ?? 0)}
-
-
- 跳过文件数
- {Number(selectedImportRetrySummary.skipped_files ?? 0)}
-
-
-
-
-
- ) : null}
-
- {selectedImportTaskErrorText ? (
-
- {selectedImportTaskErrorText}
-
- ) : null}
-
-
-
文件状态
- {selectedImportFiles.length > 0 ? (
-
-
- {selectedImportFiles.map((file) => {
- const isSelected = file.file_id === selectedImportFileId
- return (
-
void selectImportFile(file.file_id)}
- className={cn(
- 'w-full rounded-xl border p-4 text-left transition-all',
- isSelected
- ? 'border-primary/70 bg-primary/5 shadow-sm'
- : 'bg-background/80 hover:border-muted-foreground/40 hover:bg-muted/20',
- )}
- >
-
- {file.name || file.file_id}
-
- {getImportStatusLabel(String(file.status ?? ''))}
-
-
-
- {getImportStepLabel(String(file.current_step ?? ''))}
- {Number(file.progress ?? 0).toFixed(1)}%
-
-
-
- {Number(file.progress ?? 0).toFixed(1)}% · {Number(file.done_chunks ?? 0)} / {Number(file.total_chunks ?? 0)}
-
- {file.error ? (
- {file.error}
- ) : null}
-
- )
- })}
-
-
- ) : (
-
当前任务没有文件明细
- )}
-
-
-
-
-
分块状态
-
- void moveImportChunkPage(-1)}
- disabled={!canImportChunkPrev}
- >
-
-
-
- {importChunkTotal > 0
- ? `${importChunkOffset + 1}-${Math.min(importChunkOffset + IMPORT_CHUNK_PAGE_SIZE, importChunkTotal)}`
- : '0-0'}
- {' / '}
- {importChunkTotal}
-
- void moveImportChunkPage(1)}
- disabled={!canImportChunkNext}
- >
-
-
-
-
-
-
-
-
-
- 序号
- 状态
- 步骤
- 进度
- 错误 / 预览
-
-
-
- {importChunksLoading ? (
-
-
- 正在加载分块详情...
-
-
- ) : selectedImportChunks.length > 0 ? (
- selectedImportChunks.map((chunk) => (
-
- {chunk.index}
- {getImportStatusLabel(String(chunk.status ?? ''))}
- {getImportStepLabel(String(chunk.step ?? ''))}
- {Number(chunk.progress ?? 0).toFixed(1)}%
-
-
- {String(chunk.error ?? '').trim() ? (
-
- {String(chunk.error)}
-
- ) : null}
-
-
- {String(chunk.error ?? '').trim() ? '查看分块预览' : '查看内容详情'}
-
-
- {String(chunk.content_preview ?? '-') || '-'}
-
-
-
-
-
- ))
- ) : (
-
-
- 当前页没有分块数据
-
-
- )}
-
-
-
-
- >
- )}
-
-
-
-
-
-
-
-
-
-
-
- 调优任务
-
- 创建一次检索参数评估任务,完成后可在右侧列表中查看并应用最佳结果。
-
-
-
-
-
调优策略
-
先选择优化方向和搜索强度。默认的 balanced / standard 适合大多数情况。
-
-
-
-
-
决定本次调优更偏向准确率、召回率,还是两者平衡。
-
-
-
-
-
强度越高,评估更充分,但任务耗时也更长。
-
-
-
-
-
-
-
评估范围
-
控制本次任务使用多少样本,以及每次检索评估多少候选结果。
-
-
-
-
-
用于评估的样本数量。数量越大,结果越稳定。
-
setTuningSampleSize(event.target.value)} />
-
-
-
-
每次检索时用于评估的候选结果数量。
-
setTuningTopKEval(event.target.value)} />
-
-
-
- void submitTuningTask()} disabled={creatingTuning}>
-
- 创建调优任务
-
-
-
-
-
-
-
- 当前调优配置快照
- 展示当前生效的检索调优参数,便于在应用新结果前做对照。
-
-
-
-
-
-
-
-
-
- 最近调优任务
- 任务完成后,可以把最佳结果应用到当前调优配置。
-
-
-
-
-
- 任务
- 状态
- 动作
-
-
-
- {tuningTasks.length > 0 ? tuningTasks.map((task) => (
-
- {String(task.task_id ?? '-')}
-
-
- {String(task.status ?? '-')}
-
-
-
- void applyBestTask(String(task.task_id ?? ''))}
- disabled={!task.task_id}
- >
- 应用最佳
-
-
-
- )) : (
-
-
- 还没有调优任务。可以先使用默认参数创建一次评估任务。
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 来源批量删除
-
-
- 用于按来源清理测试数据或指定导入批次。该操作不会直接删除实体,只会删除来源段落和失去全部证据的关系。
-
-
-
-
-
- 建议先在图谱里确认影响范围,再在这里执行批量来源删除。所有删除都会先经过预览,并支持按删除记录恢复。
-
-
-
-
-
-
-
- setSourceSearch(event.target.value)}
- placeholder="搜索 source 名称"
- />
-
-
- setSelectedSources(filteredSources.map((item) => String(item.source ?? '')).filter(Boolean))}
- >
- 全选当前结果
-
- void openSourceDeletePreview()} disabled={selectedSources.length <= 0}>
-
- 预览删除
-
-
-
-
-
- 当前命中 {filteredSources.length} 个来源
- 0 ? 'secondary' : 'outline'} className="bg-background/70">
- 已选择 {selectedSources.length} 个来源
-
-
-
-
-
-
-
- 选中
- 来源
- 段落数
- 关系数
-
-
-
- {filteredSources.length > 0 ? filteredSources.map((item) => {
- const source = String(item.source ?? '')
- const checked = selectedSources.includes(source)
- return (
-
-
- toggleSourceSelection(source, Boolean(value))} />
-
- {source}
- {Number(item.paragraph_count ?? 0)}
- {Number(item.relation_count ?? 0)}
-
- )
- }) : (
-
-
- 当前没有可删除的来源
-
-
- )}
-
-
-
-
-
-
-
-
-
-
- 删除操作恢复
-
- 按列表浏览最近的删除操作,先选中记录,再在下方确认影响范围并执行恢复
-
-
-
- setOperationSearch(event.target.value)}
- placeholder="搜索 operation / reason / requested_by / source"
- />
-
-
-
-
-
- 当前命中 {filteredDeleteOperations.length} 条记录,已加载最近 {deleteOperations.length} 条
- 第 {operationPage} / {deleteOperationPageCount} 页,每页显示 {DELETE_OPERATION_PAGE_SIZE} 条
-
-
-
-
- {pagedDeleteOperations.length > 0 ? pagedDeleteOperations.map((operation) => {
- const summary = (operation.summary ?? {}) as Record
- const counts = ((summary.counts as Record | undefined) ?? {})
- const isSelected = selectedDeleteOperation?.operation_id === operation.operation_id
- return (
- setSelectedOperationId(operation.operation_id)}
- className={cn(
- 'w-full rounded-xl border p-4 text-left transition-colors',
- isSelected
- ? 'border-primary bg-primary/5 shadow-sm'
- : 'bg-muted/20 hover:border-primary/40 hover:bg-muted/40',
- )}
- >
-
-
-
-
- {formatDeleteOperationStatus(String(operation.status ?? ''))}
-
-
- {formatDeleteOperationMode(String(operation.mode ?? ''))}
-
-
-
{operation.operation_id}
-
- {operation.reason || '未填写原因'}
-
-
-
- 实体 {Number(counts.entities ?? 0)}
- 关系 {Number(counts.relations ?? 0)}
- 段落 {Number(counts.paragraphs ?? 0)}
- 来源 {Number(counts.sources ?? 0)}
-
-
-
- {formatDeleteOperationTime(operation.created_at)}
-
-
- )
- }) : (
-
- 当前筛选条件下没有删除操作
-
- )}
-
-
-
-
-
setOperationPage((current) => Math.max(1, current - 1))}
- disabled={operationPage <= 1}
- >
- 上一页
-
-
- 支持按删除记录、模式、状态、发起人和来源检索
-
-
setOperationPage((current) => Math.min(deleteOperationPageCount, current + 1))}
- disabled={operationPage >= deleteOperationPageCount}
- >
- 下一页
-
-
-
-
- {selectedDeleteOperation ? (
-
-
-
-
-
- {formatDeleteOperationStatus(String(selectedDeleteOperation.status ?? ''))}
-
-
- {formatDeleteOperationMode(String(selectedDeleteOperation.mode ?? ''))}
-
-
-
{selectedDeleteOperation.operation_id}
-
- {selectedDeleteOperation.reason || '未填写删除原因'}
-
-
-
void restoreDeleteOperation(selectedDeleteOperation.operation_id)}
- disabled={selectedDeleteOperation.status === 'restored' || deleteRestoring}
- >
-
- {selectedDeleteOperation.status === 'restored' ? '已恢复' : '恢复这次删除'}
-
-
-
-
-
-
发起人
-
{selectedDeleteOperation.requested_by || '-'}
-
-
-
创建时间
-
{formatDeleteOperationTime(selectedDeleteOperation.created_at)}
-
-
-
恢复时间
-
{formatDeleteOperationTime(selectedDeleteOperation.restored_at)}
-
-
-
删除摘要
-
- 实体 {Number(selectedOperationCounts.entities ?? 0)}
- 关系 {Number(selectedOperationCounts.relations ?? 0)}
- 段落 {Number(selectedOperationCounts.paragraphs ?? 0)}
- 来源 {Number(selectedOperationCounts.sources ?? 0)}
-
-
-
-
- {selectedOperationDetailLoading ? (
-
- 正在加载影响对象详情...
-
- ) : null}
-
- {selectedOperationDetailError ? (
-
- {selectedOperationDetailError}
-
- ) : null}
-
- {selectedOperationSources.length > 0 ? (
-
-
关联来源
-
- {selectedOperationSources.map((source) => (
-
- {source}
-
- ))}
-
-
- ) : null}
-
-
-
-
选择器
-
- {JSON.stringify(selectedDeleteOperation.selector ?? {}, null, 2)}
-
-
-
-
-
-
影响对象
-
- 命中 {filteredSelectedOperationItems.length} / {selectedOperationItems.length} 项
-
-
-
-
setSelectedOperationItemSearch(event.target.value)}
- placeholder="搜索对象类型 / 哈希 / 对象键 / 来源"
- className="lg:max-w-sm"
- />
-
- 第 {selectedOperationItemPage} / {selectedOperationItemPageCount} 页
- 每页 {DELETE_OPERATION_ITEM_PAGE_SIZE} 项
-
-
-
-
- {pagedSelectedOperationItems.length > 0 ? pagedSelectedOperationItems.map((item) => {
- const source = getDeleteOperationItemSource(item)
- const label = getDeleteOperationItemLabel(item)
- const preview = getDeleteOperationItemPreview(item)
- return (
-
-
- {item.item_type}
- {source ? {source} : null}
- {item.item_key && item.item_key !== item.item_hash ? (
- {item.item_key}
- ) : null}
-
-
- {label}
-
- {preview ? (
-
- {preview}
-
- ) : null}
-
- {item.item_hash}
-
-
- )
- }) : (
-
- {selectedOperationItems.length > 0 ? '当前筛选条件下没有明细项' : '当前操作没有记录明细项'}
-
- )}
-
-
-
-
setSelectedOperationItemPage((current) => Math.max(1, current - 1))}
- disabled={selectedOperationItemPage <= 1}
- >
- 上一页
-
-
- 支持按对象类型、哈希、对象键和来源检索
-
-
setSelectedOperationItemPage((current) => Math.min(selectedOperationItemPageCount, current + 1))}
- disabled={selectedOperationItemPage >= selectedOperationItemPageCount}
- >
- 下一页
-
-
-
-
-
- ) : (
-
- 当前没有可查看的删除操作详情
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
- 反馈纠错历史
-
-
- 查看 feedback correction 的判定、修改轨迹与回退结果;本期仅覆盖自动纠错任务
-
-
-
-
- setFeedbackSearch(event.target.value)}
- placeholder="搜索查询编号 / 会话 / 查询内容 / 原因"
- />
-
-
-
-
-
- 当前命中 {filteredFeedbackCorrections.length} 条记录,已加载最近 {feedbackCorrections.length} 条
- 第 {feedbackPage} / {feedbackPageCount} 页,每页显示 {FEEDBACK_CORRECTION_PAGE_SIZE} 条
-
-
-
-
-
- {pagedFeedbackCorrections.length > 0 ? pagedFeedbackCorrections.map((item) => {
- const isSelected = selectedFeedbackCorrection?.task_id === item.task_id
- const preview = getFeedbackCorrectionPreview(item)
- const impactSummary = buildFeedbackImpactSummary(item)
- return (
-
setSelectedFeedbackTaskId(item.task_id)}
- className={cn(
- 'w-full rounded-xl border p-4 text-left transition-colors',
- isSelected
- ? 'border-primary bg-primary/5 shadow-sm'
- : 'bg-muted/20 hover:border-primary/40 hover:bg-muted/40',
- )}
- >
-
-
-
-
- {formatFeedbackTaskStatus(item.task_status)}
-
-
- {formatFeedbackRollbackStatus(item.rollback_status)}
-
-
- {formatFeedbackDecision(item.decision)}
-
-
-
- {formatDeleteOperationTime(item.query_timestamp ?? item.created_at)}
-
-
-
-
- {preview.headline}
-
-
- 查询:{item.query_text || '无查询文本'}
-
-
- {(preview.oldRelation || preview.newRelation) ? (
-
-
-
-
纠错前
-
{preview.oldRelation || '无'}
-
-
→
-
-
纠错后
-
{preview.newRelation || '无'}
-
-
-
- ) : null}
-
- {impactSummary.length > 0 ? impactSummary.slice(0, 3).map((summary) => (
-
- {summary}
-
- )) : (
-
- 暂无影响摘要
-
- )}
-
-
- {item.query_tool_id}
-
-
-
- )
- }) : (
-
- 当前筛选条件下没有纠错历史
-
- )}
-
-
-
-
- {selectedFeedbackCorrection ? (
-
-
-
-
-
- {formatFeedbackTaskStatus(String(selectedFeedbackResolved?.task_status ?? ''))}
-
-
- {formatFeedbackRollbackStatus(String(selectedFeedbackResolved?.rollback_status ?? 'none'))}
-
-
- {formatFeedbackDecision(String(selectedFeedbackResolved?.decision ?? ''))}
-
-
-
- {selectedFeedbackPreview.headline}
-
-
- 查询:{selectedFeedbackResolved?.query_text || '无查询文本'}
-
-
- {selectedFeedbackResolved?.query_tool_id}
-
-
-
-
- {String(selectedFeedbackResolved?.rollback_status ?? 'none') === 'rolled_back'
- ? '已回退'
- : '回退本次纠错'}
-
-
-
-
-
-
本次纠错结论
-
-
-
纠错前
-
- {selectedFeedbackPreview.oldRelation || '当前详情没有记录旧结论'}
-
-
-
→
-
-
纠错后
-
- {selectedFeedbackPreview.newRelation || '当前详情没有记录新结论'}
-
-
-
-
-
-
-
影响范围摘要
-
- {selectedFeedbackImpactSummary.length > 0 ? selectedFeedbackImpactSummary.map((summary) => (
-
- {summary}
-
- )) : (
-
当前没有可展示的影响范围摘要
- )}
-
-
-
-
-
-
-
会话
-
{selectedFeedbackResolved?.session_id || '-'}
-
-
-
反馈消息数
-
{Number(selectedFeedbackResolved?.feedback_message_count ?? 0)}
-
-
-
判定置信度
-
{Number(selectedFeedbackResolved?.decision_confidence ?? 0).toFixed(2)}
-
-
-
回退时间
-
{formatDeleteOperationTime(selectedFeedbackResolved?.rolled_back_at)}
-
-
-
- {selectedFeedbackTaskLoading ? (
-
- 正在加载纠错详情...
-
- ) : null}
-
- {selectedFeedbackTaskError ? (
-
- {selectedFeedbackTaskError}
-
- ) : null}
-
- {selectedFeedbackResolved?.rollback_error ? (
-
- {selectedFeedbackResolved.rollback_error}
-
- ) : null}
-
-
-
-
回退后会发生什么
-
-
会恢复旧关系状态,并撤销本次纠错写入的段落与关系。
-
会清理旧段落的待复核标记,并重新触发相关 Episode / Profile 修复。
-
如果你当前只是核对结果,可以先查看下面的详细数据,不必立刻执行回退。
-
-
-
-
处理摘要
-
-
判定:{formatFeedbackDecision(String(selectedFeedbackResolved?.decision ?? ''))}
-
任务状态:{formatFeedbackTaskStatus(String(selectedFeedbackResolved?.task_status ?? ''))}
-
回退状态:{formatFeedbackRollbackStatus(String(selectedFeedbackResolved?.rollback_status ?? 'none'))}
-
反馈消息数:{Number(selectedFeedbackResolved?.feedback_message_count ?? 0)}
-
-
-
-
-
-
详细数据
-
-
- 查询快照 JSON
-
- {JSON.stringify(selectedFeedbackResolved?.query_snapshot ?? {}, null, 2)}
-
-
-
- 判定结果 JSON
-
- {JSON.stringify(selectedFeedbackResolved?.decision_payload ?? {}, null, 2)}
-
-
-
- 回退计划摘要 JSON
-
- {JSON.stringify(selectedFeedbackResolved?.rollback_plan_summary ?? {}, null, 2)}
-
-
-
- 回退结果 JSON
-
- {JSON.stringify(selectedFeedbackResolved?.rollback_result ?? {}, null, 2)}
-
-
-
-
-
-
-
- 动作时间线
-
-
-
-
- 第 {feedbackActionLogPage} / {feedbackActionLogPageCount} 页,每页 {FEEDBACK_ACTION_LOG_PAGE_SIZE} 项
-
-
setFeedbackActionLogSearch(event.target.value)}
- placeholder="搜索动作 / 目标哈希 / 预览内容"
- className="lg:w-80"
- />
-
-
-
- {pagedFeedbackActionLogs.length > 0 ? pagedFeedbackActionLogs.map((item: MemoryFeedbackActionLogPayload) => (
-
-
-
- {formatFeedbackActionType(item.action_type)}
- {item.target_hash ? (
- {item.target_hash}
- ) : null}
-
-
- {formatDeleteOperationTime(item.created_at)}
-
-
-
- {describeFeedbackActionLog(item)}
-
- {item.reason ? (
-
- 原因:{item.reason}
-
- ) : null}
- {item.before_payload && Object.keys(item.before_payload).length > 0 ? (
-
- 处理前:
- {summarizeFeedbackActionPayload(item.before_payload)}
-
- ) : null}
- {item.after_payload && Object.keys(item.after_payload).length > 0 ? (
-
- 处理后:
- {summarizeFeedbackActionPayload(item.after_payload)}
-
- ) : null}
-
- )) : (
-
- {selectedFeedbackActionLogs.length > 0 ? '当前筛选条件下没有动作日志' : '当前任务没有动作日志'}
-
- )}
-
-
-
-
setFeedbackActionLogPage((current) => Math.max(1, current - 1))}
- disabled={feedbackActionLogPage <= 1}
- >
- 上一页
-
-
支持按动作类型、目标哈希和摘要检索
-
setFeedbackActionLogPage((current) => Math.min(feedbackActionLogPageCount, current + 1))}
- disabled={feedbackActionLogPage >= feedbackActionLogPageCount}
- >
- 下一页
-
-
-
-
-
- ) : (
-
- 当前没有可查看的纠错详情
-
- )}
-
-
-
-
-
setFeedbackPage((current) => Math.max(1, current - 1))}
- disabled={feedbackPage <= 1}
- >
- 上一页
-
-
- 支持按查询内容、任务状态和回退状态检索
-
-
setFeedbackPage((current) => Math.min(feedbackPageCount, current + 1))}
- disabled={feedbackPage >= feedbackPageCount}
- >
- 下一页
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/dashboard/src/routes/resource/knowledge-base/constants.ts b/dashboard/src/routes/resource/knowledge-base/constants.ts
new file mode 100644
index 0000000000..39d2543bcd
--- /dev/null
+++ b/dashboard/src/routes/resource/knowledge-base/constants.ts
@@ -0,0 +1,52 @@
+import type { MemoryImportTaskKind } from '@/lib/memory-api'
+
+export const DELETE_OPERATION_FETCH_LIMIT = 100
+export const DELETE_OPERATION_PAGE_SIZE = 6
+export const DELETE_OPERATION_ITEM_PAGE_SIZE = 8
+export const FEEDBACK_CORRECTION_FETCH_LIMIT = 100
+export const FEEDBACK_CORRECTION_PAGE_SIZE = 6
+export const FEEDBACK_ACTION_LOG_PAGE_SIZE = 8
+export const IMPORT_CHUNK_PAGE_SIZE = 50
+
+export const RUNNING_IMPORT_STATUS = new Set(['preparing', 'running', 'cancel_requested'])
+export const QUEUED_IMPORT_STATUS = new Set(['queued'])
+
+export const IMPORT_STATUS_TEXT: Record
= {
+ queued: '排队中',
+ preparing: '准备中',
+ running: '运行中',
+ cancel_requested: '取消中',
+ cancelled: '已取消',
+ completed: '已完成',
+ completed_with_errors: '完成(有错误)',
+ failed: '失败',
+}
+
+export const IMPORT_STEP_TEXT: Record = {
+ queued: '排队中',
+ preparing: '准备中',
+ running: '运行中',
+ splitting: '分块中',
+ extracting: '抽取中',
+ writing: '写入中',
+ saving: '保存中',
+ backfilling: '回填中',
+ converting: '转换中',
+ verifying: '校验中',
+ switching: '切换中',
+ cancel_requested: '取消中',
+ cancelled: '已取消',
+ completed: '已完成',
+ completed_with_errors: '完成(有错误)',
+ failed: '失败',
+}
+
+export const IMPORT_KIND_OPTIONS: Array<{ value: MemoryImportTaskKind; label: string; description: string }> = [
+ { value: 'upload', label: '上传文件', description: '从本地批量上传资料文件' },
+ { value: 'paste', label: '粘贴导入', description: '直接粘贴文本或 JSON 内容创建任务' },
+ { value: 'raw_scan', label: '本地扫描', description: '按路径别名和匹配规则批量扫描导入' },
+ { value: 'lpmm_openie', label: 'LPMM OpenIE', description: '读取 LPMM 数据并抽取关系' },
+ { value: 'lpmm_convert', label: 'LPMM 转换', description: '将 LPMM 数据转换到目标目录' },
+ { value: 'temporal_backfill', label: '时序回填', description: '为已有数据补充时间字段' },
+ { value: 'maibot_migration', label: 'MaiBot 迁移', description: '从 MaiBot 历史数据迁移长期记忆' },
+]
diff --git a/dashboard/src/routes/resource/knowledge-base/tabs/DeleteTab.tsx b/dashboard/src/routes/resource/knowledge-base/tabs/DeleteTab.tsx
new file mode 100644
index 0000000000..ba67a891ac
--- /dev/null
+++ b/dashboard/src/routes/resource/knowledge-base/tabs/DeleteTab.tsx
@@ -0,0 +1,492 @@
+import type { Dispatch, SetStateAction } from 'react'
+
+import { CircleAlert, RotateCcw, Trash2 } from 'lucide-react'
+
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { ScrollArea } from '@/components/ui/scroll-area'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
+import { TabsContent } from '@/components/ui/tabs'
+import { cn } from '@/lib/utils'
+import type { MemoryDeleteOperationPayload, MemorySourceItemPayload } from '@/lib/memory-api'
+
+import { DELETE_OPERATION_ITEM_PAGE_SIZE, DELETE_OPERATION_PAGE_SIZE } from '../constants'
+import {
+ formatDeleteOperationMode,
+ formatDeleteOperationStatus,
+ formatDeleteOperationTime,
+ getDeleteOperationItemLabel,
+ getDeleteOperationItemPreview,
+ getDeleteOperationItemSource,
+ type DeleteOperationItem,
+} from '../utils'
+
+export interface DeleteTabProps {
+ sourceSearch: string
+ setSourceSearch: Dispatch>
+ selectedSources: string[]
+ setSelectedSources: Dispatch>
+ filteredSources: MemorySourceItemPayload[]
+ openSourceDeletePreview: () => Promise
+ toggleSourceSelection: (source: string, checked: boolean) => void
+
+ operationSearch: string
+ setOperationSearch: Dispatch>
+ operationModeFilter: string
+ setOperationModeFilter: Dispatch>
+ operationStatusFilter: string
+ setOperationStatusFilter: Dispatch>
+ filteredDeleteOperations: MemoryDeleteOperationPayload[]
+ deleteOperations: MemoryDeleteOperationPayload[]
+ operationPage: number
+ setOperationPage: Dispatch>
+ deleteOperationPageCount: number
+ pagedDeleteOperations: MemoryDeleteOperationPayload[]
+ selectedDeleteOperation: MemoryDeleteOperationPayload | null
+ setSelectedOperationId: Dispatch>
+ restoreDeleteOperation: (operationId: string) => Promise
+ deleteRestoring: boolean
+ selectedOperationCounts: Record
+ selectedOperationDetailLoading: boolean
+ selectedOperationDetailError: string
+ selectedOperationSources: string[]
+ selectedOperationItems: DeleteOperationItem[]
+ filteredSelectedOperationItems: DeleteOperationItem[]
+ selectedOperationItemSearch: string
+ setSelectedOperationItemSearch: Dispatch>
+ selectedOperationItemPage: number
+ setSelectedOperationItemPage: Dispatch>
+ selectedOperationItemPageCount: number
+ pagedSelectedOperationItems: DeleteOperationItem[]
+}
+
+export function DeleteTab(props: DeleteTabProps) {
+ const {
+ sourceSearch,
+ setSourceSearch,
+ selectedSources,
+ setSelectedSources,
+ filteredSources,
+ openSourceDeletePreview,
+ toggleSourceSelection,
+ operationSearch,
+ setOperationSearch,
+ operationModeFilter,
+ setOperationModeFilter,
+ operationStatusFilter,
+ setOperationStatusFilter,
+ filteredDeleteOperations,
+ deleteOperations,
+ operationPage,
+ setOperationPage,
+ deleteOperationPageCount,
+ pagedDeleteOperations,
+ selectedDeleteOperation,
+ setSelectedOperationId,
+ restoreDeleteOperation,
+ deleteRestoring,
+ selectedOperationCounts,
+ selectedOperationDetailLoading,
+ selectedOperationDetailError,
+ selectedOperationSources,
+ selectedOperationItems,
+ filteredSelectedOperationItems,
+ selectedOperationItemSearch,
+ setSelectedOperationItemSearch,
+ selectedOperationItemPage,
+ setSelectedOperationItemPage,
+ selectedOperationItemPageCount,
+ pagedSelectedOperationItems,
+ } = props
+
+ return (
+
+
+
+
+
+
+
+ 来源批量删除
+
+
+ 用于按来源清理测试数据或指定导入批次。该操作不会直接删除实体,只会删除来源段落和失去全部证据的关系。
+
+
+
+
+
+ 建议先在图谱里确认影响范围,再在这里执行批量来源删除。所有删除都会先经过预览,并支持按删除记录恢复。
+
+
+
+
+
+
+
+ setSourceSearch(event.target.value)}
+ placeholder="搜索 source 名称"
+ />
+
+
+ setSelectedSources(filteredSources.map((item) => String(item.source ?? '')).filter(Boolean))}
+ >
+ 全选当前结果
+
+ void openSourceDeletePreview()} disabled={selectedSources.length <= 0}>
+
+ 预览删除
+
+
+
+
+
+ 当前命中 {filteredSources.length} 个来源
+ 0 ? 'secondary' : 'outline'} className="bg-background/70">
+ 已选择 {selectedSources.length} 个来源
+
+
+
+
+
+
+
+ 选中
+ 来源
+ 段落数
+ 关系数
+
+
+
+ {filteredSources.length > 0 ? filteredSources.map((item) => {
+ const source = String(item.source ?? '')
+ const checked = selectedSources.includes(source)
+ return (
+
+
+ toggleSourceSelection(source, Boolean(value))} />
+
+ {source}
+ {Number(item.paragraph_count ?? 0)}
+ {Number(item.relation_count ?? 0)}
+
+ )
+ }) : (
+
+
+ 当前没有可删除的来源
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ 删除操作恢复
+
+ 按列表浏览最近的删除操作,先选中记录,再在下方确认影响范围并执行恢复
+
+
+
+ setOperationSearch(event.target.value)}
+ placeholder="搜索 operation / reason / requested_by / source"
+ />
+
+
+
+
+
+ 当前命中 {filteredDeleteOperations.length} 条记录,已加载最近 {deleteOperations.length} 条
+ 第 {operationPage} / {deleteOperationPageCount} 页,每页显示 {DELETE_OPERATION_PAGE_SIZE} 条
+
+
+
+
+ {pagedDeleteOperations.length > 0 ? pagedDeleteOperations.map((operation) => {
+ const summary = (operation.summary ?? {}) as Record
+ const counts = ((summary.counts as Record | undefined) ?? {})
+ const isSelected = selectedDeleteOperation?.operation_id === operation.operation_id
+ return (
+ setSelectedOperationId(operation.operation_id)}
+ className={cn(
+ 'w-full rounded-xl border p-4 text-left transition-colors',
+ isSelected
+ ? 'border-primary bg-primary/5 shadow-sm'
+ : 'bg-muted/20 hover:border-primary/40 hover:bg-muted/40',
+ )}
+ >
+
+
+
+
+ {formatDeleteOperationStatus(String(operation.status ?? ''))}
+
+
+ {formatDeleteOperationMode(String(operation.mode ?? ''))}
+
+
+
{operation.operation_id}
+
+ {operation.reason || '未填写原因'}
+
+
+
+ 实体 {Number(counts.entities ?? 0)}
+ 关系 {Number(counts.relations ?? 0)}
+ 段落 {Number(counts.paragraphs ?? 0)}
+ 来源 {Number(counts.sources ?? 0)}
+
+
+
+ {formatDeleteOperationTime(operation.created_at)}
+
+
+ )
+ }) : (
+
+ 当前筛选条件下没有删除操作
+
+ )}
+
+
+
+
+
setOperationPage((current) => Math.max(1, current - 1))}
+ disabled={operationPage <= 1}
+ >
+ 上一页
+
+
+ 支持按删除记录、模式、状态、发起人和来源检索
+
+
setOperationPage((current) => Math.min(deleteOperationPageCount, current + 1))}
+ disabled={operationPage >= deleteOperationPageCount}
+ >
+ 下一页
+
+
+
+
+ {selectedDeleteOperation ? (
+
+
+
+
+
+ {formatDeleteOperationStatus(String(selectedDeleteOperation.status ?? ''))}
+
+
+ {formatDeleteOperationMode(String(selectedDeleteOperation.mode ?? ''))}
+
+
+
{selectedDeleteOperation.operation_id}
+
+ {selectedDeleteOperation.reason || '未填写删除原因'}
+
+
+
void restoreDeleteOperation(selectedDeleteOperation.operation_id)}
+ disabled={selectedDeleteOperation.status === 'restored' || deleteRestoring}
+ >
+
+ {selectedDeleteOperation.status === 'restored' ? '已恢复' : '恢复这次删除'}
+
+
+
+
+
+
发起人
+
{selectedDeleteOperation.requested_by || '-'}
+
+
+
创建时间
+
{formatDeleteOperationTime(selectedDeleteOperation.created_at)}
+
+
+
恢复时间
+
{formatDeleteOperationTime(selectedDeleteOperation.restored_at)}
+
+
+
删除摘要
+
+ 实体 {Number(selectedOperationCounts.entities ?? 0)}
+ 关系 {Number(selectedOperationCounts.relations ?? 0)}
+ 段落 {Number(selectedOperationCounts.paragraphs ?? 0)}
+ 来源 {Number(selectedOperationCounts.sources ?? 0)}
+
+
+
+
+ {selectedOperationDetailLoading ? (
+
+ 正在加载影响对象详情...
+
+ ) : null}
+
+ {selectedOperationDetailError ? (
+
+ {selectedOperationDetailError}
+
+ ) : null}
+
+ {selectedOperationSources.length > 0 ? (
+
+
关联来源
+
+ {selectedOperationSources.map((source) => (
+
+ {source}
+
+ ))}
+
+
+ ) : null}
+
+
+
+
选择器
+
+ {JSON.stringify(selectedDeleteOperation.selector ?? {}, null, 2)}
+
+
+
+
+
+
影响对象
+
+ 命中 {filteredSelectedOperationItems.length} / {selectedOperationItems.length} 项
+
+
+
+
setSelectedOperationItemSearch(event.target.value)}
+ placeholder="搜索对象类型 / 哈希 / 对象键 / 来源"
+ className="lg:max-w-sm"
+ />
+
+ 第 {selectedOperationItemPage} / {selectedOperationItemPageCount} 页
+ 每页 {DELETE_OPERATION_ITEM_PAGE_SIZE} 项
+
+
+
+
+ {pagedSelectedOperationItems.length > 0 ? pagedSelectedOperationItems.map((item) => {
+ const source = getDeleteOperationItemSource(item)
+ const label = getDeleteOperationItemLabel(item)
+ const preview = getDeleteOperationItemPreview(item)
+ return (
+
+
+ {item.item_type}
+ {source ? {source} : null}
+ {item.item_key && item.item_key !== item.item_hash ? (
+ {item.item_key}
+ ) : null}
+
+
+ {label}
+
+ {preview ? (
+
+ {preview}
+
+ ) : null}
+
+ {item.item_hash}
+
+
+ )
+ }) : (
+
+ {selectedOperationItems.length > 0 ? '当前筛选条件下没有明细项' : '当前操作没有记录明细项'}
+
+ )}
+
+
+
+
setSelectedOperationItemPage((current) => Math.max(1, current - 1))}
+ disabled={selectedOperationItemPage <= 1}
+ >
+ 上一页
+
+
+ 支持按对象类型、哈希、对象键和来源检索
+
+
setSelectedOperationItemPage((current) => Math.min(selectedOperationItemPageCount, current + 1))}
+ disabled={selectedOperationItemPage >= selectedOperationItemPageCount}
+ >
+ 下一页
+
+
+
+
+
+ ) : (
+
+ 当前没有可查看的删除操作详情
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/dashboard/src/routes/resource/knowledge-base/tabs/FeedbackTab.tsx b/dashboard/src/routes/resource/knowledge-base/tabs/FeedbackTab.tsx
new file mode 100644
index 0000000000..f63c7978ee
--- /dev/null
+++ b/dashboard/src/routes/resource/knowledge-base/tabs/FeedbackTab.tsx
@@ -0,0 +1,512 @@
+import type { Dispatch, SetStateAction } from 'react'
+
+import { RotateCcw } from 'lucide-react'
+
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { ScrollArea } from '@/components/ui/scroll-area'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { TabsContent } from '@/components/ui/tabs'
+import { cn } from '@/lib/utils'
+import type {
+ MemoryFeedbackActionLogPayload,
+ MemoryFeedbackCorrectionDetailTaskPayload,
+ MemoryFeedbackCorrectionSummaryPayload,
+} from '@/lib/memory-api'
+
+import { FEEDBACK_ACTION_LOG_PAGE_SIZE, FEEDBACK_CORRECTION_PAGE_SIZE } from '../constants'
+import {
+ buildFeedbackImpactSummary,
+ describeFeedbackActionLog,
+ formatDeleteOperationTime,
+ formatFeedbackActionType,
+ formatFeedbackDecision,
+ formatFeedbackRollbackStatus,
+ formatFeedbackTaskStatus,
+ getFeedbackCorrectionPreview,
+ getFeedbackStatusVariant,
+ summarizeFeedbackActionPayload,
+} from '../utils'
+
+export interface FeedbackTabProps {
+ feedbackSearch: string
+ setFeedbackSearch: Dispatch>
+ feedbackStatusFilter: string
+ setFeedbackStatusFilter: Dispatch>
+ feedbackRollbackFilter: string
+ setFeedbackRollbackFilter: Dispatch>
+ filteredFeedbackCorrections: MemoryFeedbackCorrectionSummaryPayload[]
+ feedbackCorrections: MemoryFeedbackCorrectionSummaryPayload[]
+ pagedFeedbackCorrections: MemoryFeedbackCorrectionSummaryPayload[]
+ feedbackPage: number
+ setFeedbackPage: Dispatch>
+ feedbackPageCount: number
+ selectedFeedbackCorrection: MemoryFeedbackCorrectionSummaryPayload | null
+ setSelectedFeedbackTaskId: Dispatch>
+ selectedFeedbackResolved: MemoryFeedbackCorrectionDetailTaskPayload | null
+ selectedFeedbackPreview: ReturnType
+ selectedFeedbackImpactSummary: string[]
+ openFeedbackRollbackDialog: () => void
+ feedbackRollingBack: boolean
+ selectedFeedbackTaskLoading: boolean
+ selectedFeedbackTaskError: string | null
+ feedbackActionLogPage: number
+ setFeedbackActionLogPage: Dispatch>
+ feedbackActionLogPageCount: number
+ feedbackActionLogSearch: string
+ setFeedbackActionLogSearch: Dispatch>
+ pagedFeedbackActionLogs: MemoryFeedbackActionLogPayload[]
+ selectedFeedbackActionLogs: MemoryFeedbackActionLogPayload[]
+}
+
+export function FeedbackTab(props: FeedbackTabProps) {
+ const {
+ feedbackSearch,
+ setFeedbackSearch,
+ feedbackStatusFilter,
+ setFeedbackStatusFilter,
+ feedbackRollbackFilter,
+ setFeedbackRollbackFilter,
+ filteredFeedbackCorrections,
+ feedbackCorrections,
+ pagedFeedbackCorrections,
+ feedbackPage,
+ setFeedbackPage,
+ feedbackPageCount,
+ selectedFeedbackCorrection,
+ setSelectedFeedbackTaskId,
+ selectedFeedbackResolved,
+ selectedFeedbackPreview,
+ selectedFeedbackImpactSummary,
+ openFeedbackRollbackDialog,
+ feedbackRollingBack,
+ selectedFeedbackTaskLoading,
+ selectedFeedbackTaskError,
+ feedbackActionLogPage,
+ setFeedbackActionLogPage,
+ feedbackActionLogPageCount,
+ feedbackActionLogSearch,
+ setFeedbackActionLogSearch,
+ pagedFeedbackActionLogs,
+ selectedFeedbackActionLogs,
+ } = props
+
+ return (
+
+
+
+
+
+
+ 反馈纠错历史
+
+
+ 查看 feedback correction 的判定、修改轨迹与回退结果;本期仅覆盖自动纠错任务
+
+
+
+
+ setFeedbackSearch(event.target.value)}
+ placeholder="搜索查询编号 / 会话 / 查询内容 / 原因"
+ />
+
+
+
+
+
+ 当前命中 {filteredFeedbackCorrections.length} 条记录,已加载最近 {feedbackCorrections.length} 条
+ 第 {feedbackPage} / {feedbackPageCount} 页,每页显示 {FEEDBACK_CORRECTION_PAGE_SIZE} 条
+
+
+
+
+
+ {pagedFeedbackCorrections.length > 0 ? pagedFeedbackCorrections.map((item) => {
+ const isSelected = selectedFeedbackCorrection?.task_id === item.task_id
+ const preview = getFeedbackCorrectionPreview(item)
+ const impactSummary = buildFeedbackImpactSummary(item)
+ return (
+
setSelectedFeedbackTaskId(item.task_id)}
+ className={cn(
+ 'w-full rounded-xl border p-4 text-left transition-colors',
+ isSelected
+ ? 'border-primary bg-primary/5 shadow-sm'
+ : 'bg-muted/20 hover:border-primary/40 hover:bg-muted/40',
+ )}
+ >
+
+
+
+
+ {formatFeedbackTaskStatus(item.task_status)}
+
+
+ {formatFeedbackRollbackStatus(item.rollback_status)}
+
+
+ {formatFeedbackDecision(item.decision)}
+
+
+
+ {formatDeleteOperationTime(item.query_timestamp ?? item.created_at)}
+
+
+
+
+ {preview.headline}
+
+
+ 查询:{item.query_text || '无查询文本'}
+
+
+ {(preview.oldRelation || preview.newRelation) ? (
+
+
+
+
纠错前
+
{preview.oldRelation || '无'}
+
+
→
+
+
纠错后
+
{preview.newRelation || '无'}
+
+
+
+ ) : null}
+
+ {impactSummary.length > 0 ? impactSummary.slice(0, 3).map((summary) => (
+
+ {summary}
+
+ )) : (
+
+ 暂无影响摘要
+
+ )}
+
+
+ {item.query_tool_id}
+
+
+
+ )
+ }) : (
+
+ 当前筛选条件下没有纠错历史
+
+ )}
+
+
+
+
+ {selectedFeedbackCorrection ? (
+
+
+
+
+
+ {formatFeedbackTaskStatus(String(selectedFeedbackResolved?.task_status ?? ''))}
+
+
+ {formatFeedbackRollbackStatus(String(selectedFeedbackResolved?.rollback_status ?? 'none'))}
+
+
+ {formatFeedbackDecision(String(selectedFeedbackResolved?.decision ?? ''))}
+
+
+
+ {selectedFeedbackPreview.headline}
+
+
+ 查询:{selectedFeedbackResolved?.query_text || '无查询文本'}
+
+
+ {selectedFeedbackResolved?.query_tool_id}
+
+
+
+
+ {String(selectedFeedbackResolved?.rollback_status ?? 'none') === 'rolled_back'
+ ? '已回退'
+ : '回退本次纠错'}
+
+
+
+
+
+
本次纠错结论
+
+
+
纠错前
+
+ {selectedFeedbackPreview.oldRelation || '当前详情没有记录旧结论'}
+
+
+
→
+
+
纠错后
+
+ {selectedFeedbackPreview.newRelation || '当前详情没有记录新结论'}
+
+
+
+
+
+
+
影响范围摘要
+
+ {selectedFeedbackImpactSummary.length > 0 ? selectedFeedbackImpactSummary.map((summary) => (
+
+ {summary}
+
+ )) : (
+
当前没有可展示的影响范围摘要
+ )}
+
+
+
+
+
+
+
会话
+
{selectedFeedbackResolved?.session_id || '-'}
+
+
+
反馈消息数
+
{Number(selectedFeedbackResolved?.feedback_message_count ?? 0)}
+
+
+
判定置信度
+
{Number(selectedFeedbackResolved?.decision_confidence ?? 0).toFixed(2)}
+
+
+
回退时间
+
{formatDeleteOperationTime(selectedFeedbackResolved?.rolled_back_at)}
+
+
+
+ {selectedFeedbackTaskLoading ? (
+
+ 正在加载纠错详情...
+
+ ) : null}
+
+ {selectedFeedbackTaskError ? (
+
+ {selectedFeedbackTaskError}
+
+ ) : null}
+
+ {selectedFeedbackResolved?.rollback_error ? (
+
+ {selectedFeedbackResolved.rollback_error}
+
+ ) : null}
+
+
+
+
回退后会发生什么
+
+
会恢复旧关系状态,并撤销本次纠错写入的段落与关系。
+
会清理旧段落的待复核标记,并重新触发相关 Episode / Profile 修复。
+
如果你当前只是核对结果,可以先查看下面的详细数据,不必立刻执行回退。
+
+
+
+
处理摘要
+
+
判定:{formatFeedbackDecision(String(selectedFeedbackResolved?.decision ?? ''))}
+
任务状态:{formatFeedbackTaskStatus(String(selectedFeedbackResolved?.task_status ?? ''))}
+
回退状态:{formatFeedbackRollbackStatus(String(selectedFeedbackResolved?.rollback_status ?? 'none'))}
+
反馈消息数:{Number(selectedFeedbackResolved?.feedback_message_count ?? 0)}
+
+
+
+
+
+
详细数据
+
+
+ 查询快照 JSON
+
+ {JSON.stringify(selectedFeedbackResolved?.query_snapshot ?? {}, null, 2)}
+
+
+
+ 判定结果 JSON
+
+ {JSON.stringify(selectedFeedbackResolved?.decision_payload ?? {}, null, 2)}
+
+
+
+ 回退计划摘要 JSON
+
+ {JSON.stringify(selectedFeedbackResolved?.rollback_plan_summary ?? {}, null, 2)}
+
+
+
+ 回退结果 JSON
+
+ {JSON.stringify(selectedFeedbackResolved?.rollback_result ?? {}, null, 2)}
+
+
+
+
+
+
+
+ 动作时间线
+
+
+
+
+ 第 {feedbackActionLogPage} / {feedbackActionLogPageCount} 页,每页 {FEEDBACK_ACTION_LOG_PAGE_SIZE} 项
+
+
setFeedbackActionLogSearch(event.target.value)}
+ placeholder="搜索动作 / 目标哈希 / 预览内容"
+ className="lg:w-80"
+ />
+
+
+
+ {pagedFeedbackActionLogs.length > 0 ? pagedFeedbackActionLogs.map((item: MemoryFeedbackActionLogPayload) => (
+
+
+
+ {formatFeedbackActionType(item.action_type)}
+ {item.target_hash ? (
+ {item.target_hash}
+ ) : null}
+
+
+ {formatDeleteOperationTime(item.created_at)}
+
+
+
+ {describeFeedbackActionLog(item)}
+
+ {item.reason ? (
+
+ 原因:{item.reason}
+
+ ) : null}
+ {item.before_payload && Object.keys(item.before_payload).length > 0 ? (
+
+ 处理前:
+ {summarizeFeedbackActionPayload(item.before_payload)}
+
+ ) : null}
+ {item.after_payload && Object.keys(item.after_payload).length > 0 ? (
+
+ 处理后:
+ {summarizeFeedbackActionPayload(item.after_payload)}
+
+ ) : null}
+
+ )) : (
+
+ {selectedFeedbackActionLogs.length > 0 ? '当前筛选条件下没有动作日志' : '当前任务没有动作日志'}
+
+ )}
+
+
+
+
setFeedbackActionLogPage((current) => Math.max(1, current - 1))}
+ disabled={feedbackActionLogPage <= 1}
+ >
+ 上一页
+
+
支持按动作类型、目标哈希和摘要检索
+
setFeedbackActionLogPage((current) => Math.min(feedbackActionLogPageCount, current + 1))}
+ disabled={feedbackActionLogPage >= feedbackActionLogPageCount}
+ >
+ 下一页
+
+
+
+
+
+ ) : (
+
+ 当前没有可查看的纠错详情
+
+ )}
+
+
+
+
+
setFeedbackPage((current) => Math.max(1, current - 1))}
+ disabled={feedbackPage <= 1}
+ >
+ 上一页
+
+
+ 支持按查询内容、任务状态和回退状态检索
+
+
setFeedbackPage((current) => Math.min(feedbackPageCount, current + 1))}
+ disabled={feedbackPage >= feedbackPageCount}
+ >
+ 下一页
+
+
+
+
+
+
+ )
+}
diff --git a/dashboard/src/routes/resource/knowledge-base/tabs/ImportTab.tsx b/dashboard/src/routes/resource/knowledge-base/tabs/ImportTab.tsx
new file mode 100644
index 0000000000..c284c8f81e
--- /dev/null
+++ b/dashboard/src/routes/resource/knowledge-base/tabs/ImportTab.tsx
@@ -0,0 +1,1270 @@
+import type { Dispatch, SetStateAction } from 'react'
+
+import { ChevronLeft, ChevronRight, Loader2, RefreshCw, Upload } from 'lucide-react'
+
+import { MemoryMiniTabs } from '@/components/memory/MemoryMiniTabs'
+import { MemoryProgressIndicator } from '@/components/memory/MemoryProgressIndicator'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Progress } from '@/components/ui/progress'
+import { ScrollArea } from '@/components/ui/scroll-area'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
+import { Tabs, TabsContent } from '@/components/ui/tabs'
+import { Textarea } from '@/components/ui/textarea'
+import { cn } from '@/lib/utils'
+import type {
+ MemoryImportChunkPayload,
+ MemoryImportFilePayload,
+ MemoryImportInputMode,
+ MemoryImportRetrySummary,
+ MemoryImportSettings,
+ MemoryImportTaskKind,
+ MemoryImportTaskPayload,
+} from '@/lib/memory-api'
+
+import { IMPORT_CHUNK_PAGE_SIZE, IMPORT_KIND_OPTIONS, RUNNING_IMPORT_STATUS } from '../constants'
+import {
+ formatImportTime,
+ getImportStatusLabel,
+ getImportStatusVariant,
+ getImportStepLabel,
+ normalizeImportInputMode,
+ normalizeProgress,
+} from '../utils'
+
+export interface ImportTabProps {
+ importCreateMode: MemoryImportTaskKind
+ setImportCreateMode: Dispatch>
+ importSettings: MemoryImportSettings
+ importCommonFileConcurrency: string
+ setImportCommonFileConcurrency: Dispatch>
+ importCommonChunkConcurrency: string
+ setImportCommonChunkConcurrency: Dispatch>
+ importCommonLlmEnabled: boolean
+ setImportCommonLlmEnabled: Dispatch>
+ importCommonChatLog: boolean
+ setImportCommonChatLog: Dispatch>
+ importCommonStrategyOverride: string
+ setImportCommonStrategyOverride: Dispatch>
+ importCommonDedupePolicy: string
+ setImportCommonDedupePolicy: Dispatch>
+ importCommonChatReferenceTime: string
+ setImportCommonChatReferenceTime: Dispatch>
+ importCommonForce: boolean
+ setImportCommonForce: Dispatch>
+ importCommonClearManifest: boolean
+ setImportCommonClearManifest: Dispatch>
+
+ uploadInputMode: MemoryImportInputMode
+ setUploadInputMode: Dispatch>
+ uploadFiles: File[]
+ setUploadFiles: Dispatch>
+
+ pasteName: string
+ setPasteName: Dispatch>
+ pasteMode: MemoryImportInputMode
+ setPasteMode: Dispatch>
+ pasteContent: string
+ setPasteContent: Dispatch>
+
+ rawAlias: string
+ setRawAlias: Dispatch>
+ rawInputMode: MemoryImportInputMode
+ setRawInputMode: Dispatch>
+ rawRelativePath: string
+ setRawRelativePath: Dispatch>
+ rawGlob: string
+ setRawGlob: Dispatch>
+ rawRecursive: boolean
+ setRawRecursive: Dispatch>
+
+ openieAlias: string
+ setOpenieAlias: Dispatch>
+ openieRelativePath: string
+ setOpenieRelativePath: Dispatch>
+ openieIncludeAllJson: boolean
+ setOpenieIncludeAllJson: Dispatch>
+
+ convertAlias: string
+ setConvertAlias: Dispatch>
+ convertTargetAlias: string
+ setConvertTargetAlias: Dispatch>
+ convertRelativePath: string
+ setConvertRelativePath: Dispatch>
+ convertTargetRelativePath: string
+ setConvertTargetRelativePath: Dispatch>
+ convertDimension: string
+ setConvertDimension: Dispatch>
+ convertBatchSize: string
+ setConvertBatchSize: Dispatch>
+
+ backfillAlias: string
+ setBackfillAlias: Dispatch>
+ backfillLimit: string
+ setBackfillLimit: Dispatch>
+ backfillRelativePath: string
+ setBackfillRelativePath: Dispatch>
+ backfillDryRun: boolean
+ setBackfillDryRun: Dispatch>
+ backfillNoCreatedFallback: boolean
+ setBackfillNoCreatedFallback: Dispatch>
+
+ maibotSourceDb: string
+ setMaibotSourceDb: Dispatch>
+ maibotTimeFrom: string
+ setMaibotTimeFrom: Dispatch>
+ maibotTimeTo: string
+ setMaibotTimeTo: Dispatch>
+ maibotStartId: string
+ setMaibotStartId: Dispatch>
+ maibotEndId: string
+ setMaibotEndId: Dispatch>
+ maibotStreamIds: string
+ setMaibotStreamIds: Dispatch>
+ maibotGroupIds: string
+ setMaibotGroupIds: Dispatch>
+ maibotUserIds: string
+ setMaibotUserIds: Dispatch>
+ maibotReadBatchSize: string
+ setMaibotReadBatchSize: Dispatch>
+ maibotCommitWindowRows: string
+ setMaibotCommitWindowRows: Dispatch>
+ maibotEmbedWorkers: string
+ setMaibotEmbedWorkers: Dispatch>
+ maibotNoResume: boolean
+ setMaibotNoResume: Dispatch>
+ maibotResetState: boolean
+ setMaibotResetState: Dispatch>
+ maibotDryRun: boolean
+ setMaibotDryRun: Dispatch>
+ maibotVerifyOnly: boolean
+ setMaibotVerifyOnly: Dispatch>
+
+ submitImportByMode: () => Promise
+ creatingImport: boolean
+
+ pathResolveAlias: string
+ setPathResolveAlias: Dispatch>
+ importAliasKeys: string[]
+ pathResolveRelativePath: string
+ setPathResolveRelativePath: Dispatch>
+ pathResolveMustExist: boolean
+ setPathResolveMustExist: Dispatch>
+ resolveImportPath: () => Promise
+ resolvingPath: boolean
+ pathResolveOutput: string
+
+ refreshImportQueue: () => Promise
+ runningImportTasks: MemoryImportTaskPayload[]
+ queuedImportTasks: MemoryImportTaskPayload[]
+ recentImportTasks: MemoryImportTaskPayload[]
+ selectedImportTaskId: string
+ selectImportTask: (taskId: string) => Promise
+ importAutoPolling: boolean
+ setImportAutoPolling: Dispatch>
+ importPollInterval: number
+ importErrorText: string
+
+ cancelSelectedImportTask: () => Promise
+ retrySelectedImportTask: () => Promise
+ selectedImportTaskLoading: boolean
+ selectedImportTaskResolved: MemoryImportTaskPayload | null | undefined
+ selectedImportRetrySummary: MemoryImportRetrySummary | null | undefined
+ selectedImportTaskErrorText: string
+
+ selectedImportFiles: MemoryImportFilePayload[]
+ selectedImportFileId: string
+ selectImportFile: (fileId: string) => Promise
+
+ importChunkTotal: number
+ importChunkOffset: number
+ moveImportChunkPage: (direction: -1 | 1) => Promise
+ canImportChunkPrev: boolean
+ canImportChunkNext: boolean
+ importChunksLoading: boolean
+ selectedImportChunks: MemoryImportChunkPayload[]
+}
+
+export function ImportTab(props: ImportTabProps) {
+ const {
+ importCreateMode,
+ setImportCreateMode,
+ importSettings,
+ importCommonFileConcurrency,
+ setImportCommonFileConcurrency,
+ importCommonChunkConcurrency,
+ setImportCommonChunkConcurrency,
+ importCommonLlmEnabled,
+ setImportCommonLlmEnabled,
+ importCommonChatLog,
+ setImportCommonChatLog,
+ importCommonStrategyOverride,
+ setImportCommonStrategyOverride,
+ importCommonDedupePolicy,
+ setImportCommonDedupePolicy,
+ importCommonChatReferenceTime,
+ setImportCommonChatReferenceTime,
+ importCommonForce,
+ setImportCommonForce,
+ importCommonClearManifest,
+ setImportCommonClearManifest,
+ uploadInputMode,
+ setUploadInputMode,
+ uploadFiles,
+ setUploadFiles,
+ pasteName,
+ setPasteName,
+ pasteMode,
+ setPasteMode,
+ pasteContent,
+ setPasteContent,
+ rawAlias,
+ setRawAlias,
+ rawInputMode,
+ setRawInputMode,
+ rawRelativePath,
+ setRawRelativePath,
+ rawGlob,
+ setRawGlob,
+ rawRecursive,
+ setRawRecursive,
+ openieAlias,
+ setOpenieAlias,
+ openieRelativePath,
+ setOpenieRelativePath,
+ openieIncludeAllJson,
+ setOpenieIncludeAllJson,
+ convertAlias,
+ setConvertAlias,
+ convertTargetAlias,
+ setConvertTargetAlias,
+ convertRelativePath,
+ setConvertRelativePath,
+ convertTargetRelativePath,
+ setConvertTargetRelativePath,
+ convertDimension,
+ setConvertDimension,
+ convertBatchSize,
+ setConvertBatchSize,
+ backfillAlias,
+ setBackfillAlias,
+ backfillLimit,
+ setBackfillLimit,
+ backfillRelativePath,
+ setBackfillRelativePath,
+ backfillDryRun,
+ setBackfillDryRun,
+ backfillNoCreatedFallback,
+ setBackfillNoCreatedFallback,
+ maibotSourceDb,
+ setMaibotSourceDb,
+ maibotTimeFrom,
+ setMaibotTimeFrom,
+ maibotTimeTo,
+ setMaibotTimeTo,
+ maibotStartId,
+ setMaibotStartId,
+ maibotEndId,
+ setMaibotEndId,
+ maibotStreamIds,
+ setMaibotStreamIds,
+ maibotGroupIds,
+ setMaibotGroupIds,
+ maibotUserIds,
+ setMaibotUserIds,
+ maibotReadBatchSize,
+ setMaibotReadBatchSize,
+ maibotCommitWindowRows,
+ setMaibotCommitWindowRows,
+ maibotEmbedWorkers,
+ setMaibotEmbedWorkers,
+ maibotNoResume,
+ setMaibotNoResume,
+ maibotResetState,
+ setMaibotResetState,
+ maibotDryRun,
+ setMaibotDryRun,
+ maibotVerifyOnly,
+ setMaibotVerifyOnly,
+ submitImportByMode,
+ creatingImport,
+ pathResolveAlias,
+ setPathResolveAlias,
+ importAliasKeys,
+ pathResolveRelativePath,
+ setPathResolveRelativePath,
+ pathResolveMustExist,
+ setPathResolveMustExist,
+ resolveImportPath,
+ resolvingPath,
+ pathResolveOutput,
+ refreshImportQueue,
+ runningImportTasks,
+ queuedImportTasks,
+ recentImportTasks,
+ selectedImportTaskId,
+ selectImportTask,
+ importAutoPolling,
+ setImportAutoPolling,
+ importPollInterval,
+ importErrorText,
+ cancelSelectedImportTask,
+ retrySelectedImportTask,
+ selectedImportTaskLoading,
+ selectedImportTaskResolved,
+ selectedImportRetrySummary,
+ selectedImportTaskErrorText,
+ selectedImportFiles,
+ selectedImportFileId,
+ selectImportFile,
+ importChunkTotal,
+ importChunkOffset,
+ moveImportChunkPage,
+ canImportChunkPrev,
+ canImportChunkNext,
+ importChunksLoading,
+ selectedImportChunks,
+ } = props
+
+ return (
+
+
+
+
+
+
+
+ 创建导入任务
+
+ 按“选择导入方式 → 检查公共参数 → 创建任务”的顺序完成导入。
+
+
+ setImportCreateMode(value as MemoryImportTaskKind)}
+ className="space-y-4"
+ >
+
+
+
+
+
+
+
+
公共参数
+
这些设置会应用到当前导入任务。一般保持默认即可,只在批量导入或排查问题时调整。
+
+
+
+
+
同时处理多少个文件;文件很多时再适当调高。
+
setImportCommonFileConcurrency(event.target.value)}
+ />
+
+
+
+
单个文件内并行处理多少个分块;过高会增加资源占用。
+
setImportCommonChunkConcurrency(event.target.value)}
+ />
+
+
+
+ setImportCommonLlmEnabled(Boolean(value))}
+ />
+ 启用 LLM 抽取
+
+
需要模型参与抽取,质量更高但耗时更长。
+
+
+
+ setImportCommonChatLog(Boolean(value))}
+ />
+ 按聊天日志解析
+
+
适合导入聊天记录,会尽量保留时间和对话上下文。
+
+
+
+
+
+ 高级参数(通常不用修改)
+
+
+
+
+ setImportCommonStrategyOverride(event.target.value)}
+ />
+
+
+
+ setImportCommonDedupePolicy(event.target.value)}
+ />
+
+
+
+ setImportCommonChatReferenceTime(event.target.value)}
+ />
+
+
+ setImportCommonForce(Boolean(value))}
+ />
+ 强制导入
+
+
+ setImportCommonClearManifest(Boolean(value))}
+ />
+ 清空导入清单
+
+
+
+
+
+
+
+
选择一个或多个本地文件创建导入任务,适合批量导入资料或聊天记录。
+
+
+
+
+
+
+
+ setUploadFiles(Array.from(event.target.files ?? []))}
+ />
+
+
+
已选择 {uploadFiles.length} 个文件
+
+
+
+
+
+
直接粘贴少量文本或 JSON,适合临时补充一段资料。
+
+
+
+ setPasteName(event.target.value)} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
扫描目录文件,适合本地批处理
+
+
+
+ setRawAlias(event.target.value)} />
+
+
+
+
+
+
+
+ setRawRelativePath(event.target.value)} />
+
+
+
+ setRawGlob(event.target.value)} />
+
+
+
+ setRawRecursive(Boolean(value))} />
+ 递归扫描
+
+
+
+
+
+
+
读取 LPMM 内容并抽取关系
+
+
+ setOpenieIncludeAllJson(Boolean(value))}
+ />
+ 包含全部 JSON 文件
+
+
+
+
+
+
+
+
+
+
+
为已有数据补齐时间字段
+
+
+
+ setBackfillDryRun(Boolean(value))} />
+ 只预演,不写入数据
+
+
+ setBackfillNoCreatedFallback(Boolean(value))}
+ />
+ 禁用创建时间回退
+
+
+
+
+
+
+
+
迁移 MaiBot 历史长期记忆
+
+
+
+ setMaibotNoResume(Boolean(value))} />
+ 从头开始,不继续上次进度
+
+
+ setMaibotResetState(Boolean(value))} />
+ 重置迁移状态
+
+
+ setMaibotDryRun(Boolean(value))} />
+ 只预演,不写入数据
+
+
+ setMaibotVerifyOnly(Boolean(value))} />
+ 仅校验
+
+
+
+
+
+
+
+ void submitImportByMode()} disabled={creatingImport}>
+ {creatingImport ? : }
+ 创建导入任务
+
+
+
+
+
+
+ 路径预检
+ 在创建本地扫描、转换或迁移任务前,先确认路径会被解析到哪里。
+
+
+
+
+
+
选择后端允许访问的数据根目录。
+
+
+
+
+
填写相对于路径别名的子路径,不需要填写完整磁盘路径。
+
setPathResolveRelativePath(event.target.value)}
+ placeholder="例如 exports/weekly"
+ />
+
+
+
+ setPathResolveMustExist(Boolean(value))} />
+ 要求路径已存在
+
+ void resolveImportPath()}
+ disabled={resolvingPath || !pathResolveAlias.trim()}
+ >
+ {resolvingPath ? : }
+ 解析路径
+
+
+
+
+
+
+
+
+
+
+ 导入队列
+ void refreshImportQueue()}>
+
+ 刷新
+
+
+
+
+ 查看任务是否正在运行、排队等待或已经结束。点击任务卡片可查看详情。
+
+
+ 运行中 {runningImportTasks.length}
+ 排队中 {queuedImportTasks.length}
+ 最近完成 {recentImportTasks.length}
+
+
+
+
+
+ {importErrorText ? (
+
+ {importErrorText}
+
+ ) : null}
+
+
+
+
运行中
+
{runningImportTasks.length}
+
+ {runningImportTasks.length > 0 ? (
+
+
+ {runningImportTasks.map((task) => {
+ const isSelected = task.task_id === selectedImportTaskId
+ return (
+
void selectImportTask(task.task_id)}
+ className={cn(
+ 'w-full rounded-xl border p-4 text-left transition-all',
+ isSelected
+ ? 'border-primary/70 bg-primary/5 shadow-sm'
+ : 'bg-background/80 hover:border-muted-foreground/40 hover:bg-muted/20',
+ )}
+ >
+
+
+
+ {task.task_id}
+
+
{String(task.task_kind ?? task.mode ?? '-')}
+
+
+ {getImportStatusLabel(String(task.status ?? ''))}
+
+
+
+ {getImportStepLabel(String(task.current_step ?? 'running'))}
+ {Number(task.progress ?? 0).toFixed(1)}%
+
+
+
+ )
+ })}
+
+
+ ) : (
+
当前没有运行中任务
+ )}
+
+
+
+
+
排队中
+
{queuedImportTasks.length}
+
+ {queuedImportTasks.length > 0 ? (
+
+
+ {queuedImportTasks.map((task) => {
+ const isSelected = task.task_id === selectedImportTaskId
+ return (
+
void selectImportTask(task.task_id)}
+ className={cn(
+ 'w-full rounded-xl border p-4 text-left transition-all',
+ isSelected
+ ? 'border-primary/70 bg-primary/5 shadow-sm'
+ : 'bg-background/80 hover:border-muted-foreground/40 hover:bg-muted/20',
+ )}
+ >
+
+
+
+ {task.task_id}
+
+
{String(task.task_kind ?? task.mode ?? '-')}
+
+
+ {getImportStatusLabel(String(task.status ?? ''))}
+
+
+
+ 创建时间
+ {formatImportTime(task.created_at)}
+
+
+ )
+ })}
+
+
+ ) : (
+
当前没有排队任务
+ )}
+
+
+
+
+
最近完成
+
{recentImportTasks.length}
+
+ {recentImportTasks.length > 0 ? (
+
+
+ {recentImportTasks.map((task) => {
+ const isSelected = task.task_id === selectedImportTaskId
+ return (
+
void selectImportTask(task.task_id)}
+ className={cn(
+ 'w-full rounded-xl border p-4 text-left transition-all',
+ isSelected
+ ? 'border-primary/70 bg-primary/5 shadow-sm'
+ : 'bg-background/80 hover:border-muted-foreground/40 hover:bg-muted/20',
+ )}
+ >
+
+
+
+ {task.task_id}
+
+
{String(task.task_kind ?? task.mode ?? '-')}
+
+
+ {getImportStatusLabel(String(task.status ?? ''))}
+
+
+
+ 完成进度
+ {Number(task.progress ?? 0).toFixed(1)}%
+
+
+
+ )
+ })}
+
+
+ ) : (
+
暂时没有历史任务
+ )}
+
+
+
+
+
+
+
+
+
+
任务详情
+
+ void cancelSelectedImportTask()}
+ disabled={!selectedImportTaskId}
+ >
+ 取消任务
+
+ void retrySelectedImportTask()}
+ disabled={!selectedImportTaskId}
+ >
+ 重试失败项
+
+
+
+ 支持文件级和分块级状态观察,可直接在当前页面定位失败原因
+
+
+ {selectedImportTaskLoading ? (
+
+
+ 正在加载任务详情...
+
+ ) : null}
+
+ {!selectedImportTaskResolved ? (
+
+
+
+
+
+
还没选中任务
+
+ 在左侧/上方的导入队列里点击任意任务卡片
+ 即可在这里查看进度、文件状态和分块详情
+
+
+
+ ) : (
+ <>
+
+
任务摘要
+
+
+
+
+ 任务 ID
+
+ {selectedImportTaskResolved.task_id}
+
+
+
+ 任务类型
+ {String(selectedImportTaskResolved.task_kind ?? selectedImportTaskResolved.mode ?? '-')}
+
+
+ 状态 / 步骤
+
+
+
+ {getImportStatusLabel(String(selectedImportTaskResolved.status ?? ''))}
+
+
+ {getImportStepLabel(String(selectedImportTaskResolved.current_step ?? ''))}
+
+
+
+
+
+ 进度
+
+
+
+
+
+ 创建时间
+ {formatImportTime(selectedImportTaskResolved.created_at)}
+
+
+ 更新时间
+ {formatImportTime(selectedImportTaskResolved.updated_at)}
+
+
+
+
+
+
+ {selectedImportRetrySummary ? (
+
+
重试摘要
+
+
+
+
+ 按分块重试的文件数
+ {Number(selectedImportRetrySummary.chunk_retry_files ?? 0)}
+
+
+ 按分块重试的分块数
+ {Number(selectedImportRetrySummary.chunk_retry_chunks ?? 0)}
+
+
+ 回退整文件重试数
+ {Number(selectedImportRetrySummary.file_fallback_files ?? 0)}
+
+
+ 跳过文件数
+ {Number(selectedImportRetrySummary.skipped_files ?? 0)}
+
+
+
+
+
+ ) : null}
+
+ {selectedImportTaskErrorText ? (
+
+ {selectedImportTaskErrorText}
+
+ ) : null}
+
+
+
文件状态
+ {selectedImportFiles.length > 0 ? (
+
+
+ {selectedImportFiles.map((file) => {
+ const isSelected = file.file_id === selectedImportFileId
+ return (
+
void selectImportFile(file.file_id)}
+ className={cn(
+ 'w-full rounded-xl border p-4 text-left transition-all',
+ isSelected
+ ? 'border-primary/70 bg-primary/5 shadow-sm'
+ : 'bg-background/80 hover:border-muted-foreground/40 hover:bg-muted/20',
+ )}
+ >
+
+ {file.name || file.file_id}
+
+ {getImportStatusLabel(String(file.status ?? ''))}
+
+
+
+ {getImportStepLabel(String(file.current_step ?? ''))}
+ {Number(file.progress ?? 0).toFixed(1)}%
+
+
+
+ {Number(file.progress ?? 0).toFixed(1)}% · {Number(file.done_chunks ?? 0)} / {Number(file.total_chunks ?? 0)}
+
+ {file.error ? (
+ {file.error}
+ ) : null}
+
+ )
+ })}
+
+
+ ) : (
+
当前任务没有文件明细
+ )}
+
+
+
+
+
分块状态
+
+ void moveImportChunkPage(-1)}
+ disabled={!canImportChunkPrev}
+ >
+
+
+
+ {importChunkTotal > 0
+ ? `${importChunkOffset + 1}-${Math.min(importChunkOffset + IMPORT_CHUNK_PAGE_SIZE, importChunkTotal)}`
+ : '0-0'}
+ {' / '}
+ {importChunkTotal}
+
+ void moveImportChunkPage(1)}
+ disabled={!canImportChunkNext}
+ >
+
+
+
+
+
+
+
+
+
+ 序号
+ 状态
+ 步骤
+ 进度
+ 错误 / 预览
+
+
+
+ {importChunksLoading ? (
+
+
+ 正在加载分块详情...
+
+
+ ) : selectedImportChunks.length > 0 ? (
+ selectedImportChunks.map((chunk) => (
+
+ {chunk.index}
+ {getImportStatusLabel(String(chunk.status ?? ''))}
+ {getImportStepLabel(String(chunk.step ?? ''))}
+ {Number(chunk.progress ?? 0).toFixed(1)}%
+
+
+ {String(chunk.error ?? '').trim() ? (
+
+ {String(chunk.error)}
+
+ ) : null}
+
+
+ {String(chunk.error ?? '').trim() ? '查看分块预览' : '查看内容详情'}
+
+
+ {String(chunk.content_preview ?? '-') || '-'}
+
+
+
+
+
+ ))
+ ) : (
+
+
+ 当前页没有分块数据
+
+
+ )}
+
+
+
+
+ >
+ )}
+
+
+
+ )
+}
diff --git a/dashboard/src/routes/resource/knowledge-base/tabs/TuningTab.tsx b/dashboard/src/routes/resource/knowledge-base/tabs/TuningTab.tsx
new file mode 100644
index 0000000000..31fd297431
--- /dev/null
+++ b/dashboard/src/routes/resource/knowledge-base/tabs/TuningTab.tsx
@@ -0,0 +1,193 @@
+import type { Dispatch, SetStateAction } from 'react'
+
+import { Sparkles } from 'lucide-react'
+
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { CodeEditor } from '@/components/CodeEditor'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
+import { TabsContent } from '@/components/ui/tabs'
+import type { MemoryTaskPayload } from '@/lib/memory-api'
+
+import { getImportStatusVariant } from '../utils'
+
+export interface TuningTabProps {
+ tuningObjective: string
+ setTuningObjective: Dispatch>
+ tuningIntensity: string
+ setTuningIntensity: Dispatch>
+ tuningSampleSize: string
+ setTuningSampleSize: Dispatch>
+ tuningTopKEval: string
+ setTuningTopKEval: Dispatch>
+ submitTuningTask: () => Promise
+ creatingTuning: boolean
+ tuningProfile: Record
+ tuningProfileToml: string
+ tuningTasks: MemoryTaskPayload[]
+ applyBestTask: (taskId: string) => Promise
+}
+
+export function TuningTab(props: TuningTabProps) {
+ const {
+ tuningObjective,
+ setTuningObjective,
+ tuningIntensity,
+ setTuningIntensity,
+ tuningSampleSize,
+ setTuningSampleSize,
+ tuningTopKEval,
+ setTuningTopKEval,
+ submitTuningTask,
+ creatingTuning,
+ tuningProfile,
+ tuningProfileToml,
+ tuningTasks,
+ applyBestTask,
+ } = props
+
+ return (
+
+
+
+
+
+
+ 调优任务
+
+ 创建一次检索参数评估任务,完成后可在右侧列表中查看并应用最佳结果。
+
+
+
+
+
调优策略
+
先选择优化方向和搜索强度。默认的 balanced / standard 适合大多数情况。
+
+
+
+
+
决定本次调优更偏向准确率、召回率,还是两者平衡。
+
+
+
+
+
强度越高,评估更充分,但任务耗时也更长。
+
+
+
+
+
+
+
评估范围
+
控制本次任务使用多少样本,以及每次检索评估多少候选结果。
+
+
+
+
+
用于评估的样本数量。数量越大,结果越稳定。
+
setTuningSampleSize(event.target.value)} />
+
+
+
+
每次检索时用于评估的候选结果数量。
+
setTuningTopKEval(event.target.value)} />
+
+
+
+ void submitTuningTask()} disabled={creatingTuning}>
+
+ 创建调优任务
+
+
+
+
+
+
+
+ 当前调优配置快照
+ 展示当前生效的检索调优参数,便于在应用新结果前做对照。
+
+
+
+
+
+
+
+
+
+ 最近调优任务
+ 任务完成后,可以把最佳结果应用到当前调优配置。
+
+
+
+
+
+ 任务
+ 状态
+ 动作
+
+
+
+ {tuningTasks.length > 0 ? tuningTasks.map((task) => (
+
+ {String(task.task_id ?? '-')}
+
+
+ {String(task.status ?? '-')}
+
+
+
+ void applyBestTask(String(task.task_id ?? ''))}
+ disabled={!task.task_id}
+ >
+ 应用最佳
+
+
+
+ )) : (
+
+
+ 还没有调优任务。可以先使用默认参数创建一次评估任务。
+
+
+ )}
+
+
+
+
+
+
+
+ )
+}
diff --git a/dashboard/src/routes/resource/knowledge-base/utils.ts b/dashboard/src/routes/resource/knowledge-base/utils.ts
new file mode 100644
index 0000000000..397e0164aa
--- /dev/null
+++ b/dashboard/src/routes/resource/knowledge-base/utils.ts
@@ -0,0 +1,498 @@
+import type {
+ MemoryDeleteOperationPayload,
+ MemoryFeedbackActionLogPayload,
+ MemoryFeedbackCorrectionDetailTaskPayload,
+ MemoryFeedbackCorrectionSummaryPayload,
+ MemoryImportInputMode,
+} from '@/lib/memory-api'
+
+import {
+ IMPORT_STATUS_TEXT,
+ IMPORT_STEP_TEXT,
+ QUEUED_IMPORT_STATUS,
+ RUNNING_IMPORT_STATUS,
+} from './constants'
+
+export type DeleteOperationItem = NonNullable[number]
+
+export function normalizeProgress(value: number | string | null | undefined): number {
+ const numeric = Number(value ?? 0)
+ if (!Number.isFinite(numeric)) {
+ return 0
+ }
+ if (numeric < 0) {
+ return 0
+ }
+ if (numeric > 100) {
+ return 100
+ }
+ return numeric
+}
+
+export function parseOptionalPositiveInt(input: string): number | undefined {
+ const value = input.trim()
+ if (!value) {
+ return undefined
+ }
+ const parsed = Number(value)
+ if (!Number.isInteger(parsed) || parsed <= 0) {
+ return undefined
+ }
+ return parsed
+}
+
+export function parseCommaSeparatedList(input: string): string[] {
+ return input
+ .split(',')
+ .map((item) => item.trim())
+ .filter(Boolean)
+}
+
+export function normalizeImportInputMode(value: string): MemoryImportInputMode {
+ return value === 'json' ? 'json' : 'text'
+}
+
+export function getImportStatusLabel(status: string): string {
+ const normalized = String(status ?? '').trim()
+ if (!normalized) {
+ return '-'
+ }
+ return IMPORT_STATUS_TEXT[normalized] ?? normalized
+}
+
+export function getImportStepLabel(step: string): string {
+ const normalized = String(step ?? '').trim()
+ if (!normalized) {
+ return '-'
+ }
+ return IMPORT_STEP_TEXT[normalized] ?? normalized
+}
+
+export function getImportStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
+ if (status === 'failed') {
+ return 'destructive'
+ }
+ if (status === 'completed') {
+ return 'default'
+ }
+ if (status === 'completed_with_errors' || status === 'cancelled') {
+ return 'secondary'
+ }
+ if (RUNNING_IMPORT_STATUS.has(status) || QUEUED_IMPORT_STATUS.has(status)) {
+ return 'outline'
+ }
+ return 'secondary'
+}
+
+export function formatImportTime(timestamp?: number | null): string {
+ if (!timestamp) {
+ return '-'
+ }
+ const normalized = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000
+ const value = new Date(normalized)
+ if (Number.isNaN(value.getTime())) {
+ return '-'
+ }
+ return value.toLocaleString('zh-CN', {
+ hour12: false,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ })
+}
+
+export function formatDeleteOperationMode(mode: string): string {
+ switch (mode) {
+ case 'entity':
+ return '实体'
+ case 'relation':
+ return '关系'
+ case 'paragraph':
+ return '段落'
+ case 'source':
+ return '来源'
+ case 'mixed':
+ return '混合'
+ default:
+ return mode || '未知'
+ }
+}
+
+export function formatDeleteOperationStatus(status: string): string {
+ switch (status) {
+ case 'executed':
+ return '已执行'
+ case 'restored':
+ return '已恢复'
+ default:
+ return status || '未知'
+ }
+}
+
+export function formatDeleteOperationTime(timestamp?: number | null): string {
+ if (!timestamp) {
+ return '未知时间'
+ }
+ const normalized = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000
+ const value = new Date(normalized)
+ if (Number.isNaN(value.getTime())) {
+ return '未知时间'
+ }
+ return value.toLocaleString('zh-CN', {
+ hour12: false,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ })
+}
+
+export function trimDeleteItemText(value: string, maxLength: number = 140): string {
+ const normalized = String(value ?? '').trim().replace(/\s+/g, ' ')
+ if (!normalized) {
+ return ''
+ }
+ if (normalized.length <= maxLength) {
+ return normalized
+ }
+ return `${normalized.slice(0, maxLength)}...`
+}
+
+export function formatDeleteRelationText(subject: string, predicate: string, object: string): string {
+ const left = String(subject ?? '').trim()
+ const middle = String(predicate ?? '').trim()
+ const right = String(object ?? '').trim()
+ return [left, middle, right].filter(Boolean).join(' -> ')
+}
+
+export function getDeleteOperationItemLabel(item: DeleteOperationItem): string {
+ const payload = item.payload ?? {}
+ if (item.item_type === 'entity') {
+ const entity = (payload.entity ?? {}) as Record
+ return String(entity.name ?? item.item_key ?? item.item_hash ?? '未命名实体')
+ }
+ if (item.item_type === 'relation') {
+ const relation = (payload.relation ?? {}) as Record
+ return (
+ formatDeleteRelationText(
+ String(relation.subject ?? ''),
+ String(relation.predicate ?? ''),
+ String(relation.object ?? ''),
+ ) || String(item.item_key ?? item.item_hash ?? '未命名关系')
+ )
+ }
+ if (item.item_type === 'paragraph') {
+ const paragraph = (payload.paragraph ?? {}) as Record
+ const source = String(paragraph.source ?? '').trim()
+ return source || String(item.item_key ?? item.item_hash ?? '未命名段落')
+ }
+ return String(item.item_key ?? item.item_hash ?? '未命名对象')
+}
+
+export function getDeleteOperationItemPreview(item: DeleteOperationItem): string {
+ const payload = item.payload ?? {}
+ if (item.item_type === 'entity') {
+ const paragraphLinks = Array.isArray(payload.paragraph_links) ? payload.paragraph_links : []
+ if (paragraphLinks.length > 0) {
+ return `关联段落 ${paragraphLinks.length} 个`
+ }
+ return '实体快照'
+ }
+ if (item.item_type === 'relation') {
+ const relation = (payload.relation ?? {}) as Record
+ const paragraphHashes = Array.isArray(payload.paragraph_hashes) ? payload.paragraph_hashes : []
+ const { confidence } = relation
+ const parts = []
+ if (paragraphHashes.length > 0) {
+ parts.push(`证据段落 ${paragraphHashes.length} 个`)
+ }
+ if (typeof confidence === 'number') {
+ parts.push(`置信度 ${confidence.toFixed(2)}`)
+ }
+ return parts.join(',') || '关系快照'
+ }
+ if (item.item_type === 'paragraph') {
+ const paragraph = (payload.paragraph ?? {}) as Record
+ return trimDeleteItemText(String(paragraph.content ?? ''))
+ }
+ return ''
+}
+
+export function getDeleteOperationItemSource(item: DeleteOperationItem): string {
+ const payload = item.payload ?? {}
+ if (item.item_type === 'paragraph') {
+ const paragraph = (payload.paragraph ?? {}) as Record
+ return String(paragraph.source ?? '').trim()
+ }
+ return String(payload.source ?? '').trim()
+}
+
+export function formatFeedbackDecision(decision: string): string {
+ switch (decision) {
+ case 'correct':
+ return '纠正'
+ case 'reject':
+ return '否定'
+ case 'confirm':
+ return '确认'
+ case 'supplement':
+ return '补充'
+ case 'none':
+ return '无动作'
+ default:
+ return decision || '未知'
+ }
+}
+
+export function formatFeedbackTaskStatus(status: string): string {
+ switch (status) {
+ case 'pending':
+ return '待处理'
+ case 'running':
+ return '处理中'
+ case 'applied':
+ return '已应用'
+ case 'skipped':
+ return '已跳过'
+ case 'error':
+ return '失败'
+ default:
+ return status || '未知'
+ }
+}
+
+export function formatFeedbackRollbackStatus(status: string): string {
+ switch (status) {
+ case 'none':
+ return '未回退'
+ case 'running':
+ return '回退中'
+ case 'rolled_back':
+ return '已回退'
+ case 'error':
+ return '回退失败'
+ default:
+ return status || '未知'
+ }
+}
+
+export function getFeedbackStatusVariant(
+ status: string,
+): 'default' | 'secondary' | 'destructive' | 'outline' {
+ if (status === 'applied' || status === 'rolled_back') {
+ return 'default'
+ }
+ if (status === 'error') {
+ return 'destructive'
+ }
+ if (status === 'running' || status === 'pending') {
+ return 'outline'
+ }
+ return 'secondary'
+}
+
+export function summarizeFeedbackActionPayload(value: Record | undefined): string {
+ if (!value) {
+ return ''
+ }
+ const hash = String(value.hash ?? '').trim()
+ const subject = String(value.subject ?? '').trim()
+ const predicate = String(value.predicate ?? '').trim()
+ const object = String(value.object ?? '').trim()
+ if (subject && predicate && object) {
+ return formatDeleteRelationText(subject, predicate, object)
+ }
+ if (hash) {
+ return hash
+ }
+ if (Array.isArray(value.target_hashes) && value.target_hashes.length > 0) {
+ return `targets ${value.target_hashes.length}`
+ }
+ return trimDeleteItemText(JSON.stringify(value, null, 2), 120)
+}
+
+export function pickFeedbackRelationTriplet(value: unknown): Record | null {
+ if (!value || typeof value !== 'object') {
+ return null
+ }
+ const record = value as Record
+ const subject = String(record.subject ?? '').trim()
+ const predicate = String(record.predicate ?? '').trim()
+ const object = String(record.object ?? '').trim()
+ if (!subject || !predicate || !object) {
+ return null
+ }
+ return record
+}
+
+export function formatFeedbackRelationTriplet(value: unknown): string {
+ const triplet = pickFeedbackRelationTriplet(value)
+ if (!triplet) {
+ return ''
+ }
+ return formatDeleteRelationText(
+ String(triplet.subject ?? ''),
+ String(triplet.predicate ?? ''),
+ String(triplet.object ?? ''),
+ )
+}
+
+export function getFeedbackCorrectionPreview(
+ task: MemoryFeedbackCorrectionDetailTaskPayload | MemoryFeedbackCorrectionSummaryPayload | null,
+): {
+ headline: string
+ oldRelation: string
+ newRelation: string
+} {
+ if (!task) {
+ return {
+ headline: '当前没有纠错摘要',
+ oldRelation: '',
+ newRelation: '',
+ }
+ }
+
+ const detailTask = task as MemoryFeedbackCorrectionDetailTaskPayload
+ const rollbackPlanSummary = detailTask.rollback_plan_summary ?? {}
+ const forgottenRelations = Array.isArray(rollbackPlanSummary.forgotten_relations)
+ ? rollbackPlanSummary.forgotten_relations
+ : []
+ const correctedWrite = rollbackPlanSummary.corrected_write && typeof rollbackPlanSummary.corrected_write === 'object'
+ ? rollbackPlanSummary.corrected_write
+ : {}
+ const correctedRelations = Array.isArray((correctedWrite as Record).corrected_relations)
+ ? ((correctedWrite as Record).corrected_relations as unknown[])
+ : []
+
+ const oldRelation = formatFeedbackRelationTriplet(forgottenRelations[0])
+ const newRelation = formatFeedbackRelationTriplet(correctedRelations[0])
+
+ if (oldRelation && newRelation) {
+ return {
+ headline: `将“${oldRelation}”纠正为“${newRelation}”`,
+ oldRelation,
+ newRelation,
+ }
+ }
+ if (newRelation) {
+ return {
+ headline: `补充了新的纠错结论:“${newRelation}”`,
+ oldRelation: '',
+ newRelation,
+ }
+ }
+ if (oldRelation) {
+ return {
+ headline: `撤销了旧记忆关系:“${oldRelation}”`,
+ oldRelation,
+ newRelation: '',
+ }
+ }
+ return {
+ headline: task.query_text || '当前纠错没有可读摘要',
+ oldRelation: '',
+ newRelation: '',
+ }
+}
+
+export function buildFeedbackImpactSummary(
+ task: MemoryFeedbackCorrectionDetailTaskPayload | MemoryFeedbackCorrectionSummaryPayload | null,
+): string[] {
+ if (!task) {
+ return []
+ }
+
+ const counts = task.affected_counts ?? {}
+ const items: string[] = []
+ if (Number(counts.relations ?? 0) > 0) {
+ items.push(`影响关系 ${Number(counts.relations ?? 0)} 条`)
+ }
+ if (Number(counts.corrected_relations ?? 0) > 0) {
+ items.push(`新增纠正关系 ${Number(counts.corrected_relations ?? 0)} 条`)
+ }
+ if (Number(counts.correction_paragraphs ?? 0) > 0) {
+ items.push(`写入纠错段落 ${Number(counts.correction_paragraphs ?? 0)} 条`)
+ }
+ if (Number(counts.stale_paragraphs ?? 0) > 0) {
+ items.push(`标记旧段落 ${Number(counts.stale_paragraphs ?? 0)} 条`)
+ }
+ if (Number(counts.episode_sources ?? 0) > 0) {
+ items.push(`触发 Episode 修复 ${Number(counts.episode_sources ?? 0)} 个来源`)
+ }
+ if (Number(counts.profile_person_ids ?? 0) > 0) {
+ items.push(`触发 Profile 刷新 ${Number(counts.profile_person_ids ?? 0)} 个对象`)
+ }
+ return items
+}
+
+export function formatFeedbackActionType(actionType: string): string {
+ switch (actionType) {
+ case 'classification':
+ return '判定纠错'
+ case 'forget_relation':
+ return '撤销旧关系'
+ case 'mark_stale_paragraph':
+ return '标记旧段落'
+ case 'write_correction':
+ return '写入纠错'
+ case 'rollback_restore_relation':
+ return '恢复旧关系'
+ case 'rollback_delete_correction_paragraph':
+ return '隐藏纠错段落'
+ case 'rollback_revert_corrected_relation':
+ return '撤销纠正关系'
+ case 'rollback_clear_stale_mark':
+ return '清除脏段落标记'
+ case 'rollback_enqueue_episode_rebuild':
+ return '加入 Episode 修复队列'
+ case 'rollback_enqueue_profile_refresh':
+ return '加入 Profile 刷新队列'
+ case 'rollback_error':
+ return '回退失败'
+ case 'error':
+ return '处理失败'
+ case 'skip':
+ return '跳过处理'
+ default:
+ return actionType || '未知动作'
+ }
+}
+
+export function describeFeedbackActionLog(item: MemoryFeedbackActionLogPayload): string {
+ const beforeSummary = summarizeFeedbackActionPayload(item.before_payload)
+ const afterSummary = summarizeFeedbackActionPayload(item.after_payload)
+
+ switch (item.action_type) {
+ case 'classification':
+ return afterSummary ? `系统完成判定:${afterSummary}` : '系统完成纠错判定'
+ case 'forget_relation':
+ return beforeSummary ? `旧关系已失效:${beforeSummary}` : '旧关系已被标记为失效'
+ case 'mark_stale_paragraph':
+ return '旧段落已标记为待复核,后续检索会更谨慎地使用它'
+ case 'write_correction':
+ return afterSummary ? `已写入新的纠错结果:${afterSummary}` : '已写入新的纠错段落和关系'
+ case 'rollback_restore_relation':
+ return afterSummary ? `已恢复旧关系状态:${afterSummary}` : '已恢复旧关系状态'
+ case 'rollback_delete_correction_paragraph':
+ return '已隐藏这次纠错写入的段落'
+ case 'rollback_revert_corrected_relation':
+ return '已撤销纠错阶段新增的关系'
+ case 'rollback_clear_stale_mark':
+ return '已清除旧段落的待复核标记'
+ case 'rollback_enqueue_episode_rebuild':
+ return '已重新加入 Episode 修复队列'
+ case 'rollback_enqueue_profile_refresh':
+ return '已重新加入 Profile 刷新队列'
+ case 'rollback_error':
+ return item.reason || '这次回退执行失败'
+ case 'error':
+ return item.reason || '这次纠错处理失败'
+ case 'skip':
+ return item.reason || '这次纠错被跳过'
+ default:
+ return afterSummary || beforeSummary || item.reason || '记录了一条动作日志'
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index 5602ae65e2..9e71c76f80 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,22 +1,4 @@
services:
- adapters:
- container_name: maim-bot-adapters
- #### prod ####
- image: unclas/maimbot-adapter:latest
- # image: infinitycat/maimbot-adapter:latest
- #### dev ####
- # image: unclas/maimbot-adapter:dev
- # image: infinitycat/maimbot-adapter:dev
- environment:
- - TZ=Asia/Shanghai
-# ports:
-# - "8095:8095"
- volumes:
- - ./docker-config/adapters/config.toml:/adapters/config.toml # 持久化adapters配置文件
- - ./data/adapters:/adapters/data # adapters 数据持久化
- restart: always
- networks:
- - maim_bot
core:
container_name: maim-bot-core
#### prod ####
@@ -27,6 +9,8 @@ services:
# image: infinitycat/maibot:dev
environment:
- TZ=Asia/Shanghai
+ - EULA_AGREE=1b662741904d7155d1ce1c00b3530d0d
+ - PRIVACY_AGREE=9943b855e72199d0f5016ea39052f1b6
- MAIBOT_LEGACY_0X_UPGRADE_CONFIRMED=1 # Docker 无法交互确认旧版升级迁移,默认跳过确认提示
# - EULA_AGREE=1b662741904d7155d1ce1c00b3530d0d # 同意EULA
# - PRIVACY_AGREE=9943b855e72199d0f5016ea39052f1b6 # 同意EULA
@@ -36,7 +20,6 @@ services:
volumes:
# 监听地址和端口已迁移到 ./docker-config/mmc/bot_config.toml 的 maim_message 与 webui 配置段
- ./docker-config/mmc:/MaiMBot/config # 持久化bot配置文件
- - ./docker-config/adapters:/MaiMBot/adapters-config # adapter配置文件夹映射
- ./data/MaiMBot/maibot_statistics.html:/MaiMBot/maibot_statistics.html #统计数据输出
- ./data/MaiMBot:/MaiMBot/data # 共享目录
- ./data/MaiMBot/plugins:/MaiMBot/plugins # 插件目录
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
new file mode 100644
index 0000000000..c439e9a868
--- /dev/null
+++ b/docker-entrypoint.sh
@@ -0,0 +1,13 @@
+#!/bin/sh
+set -eu
+
+ADAPTER_TEMPLATE="/MaiMBot/plugin-templates/MaiBot-Napcat-Adapter"
+ADAPTER_TARGET="/MaiMBot/plugins/MaiBot-Napcat-Adapter"
+
+mkdir -p /MaiMBot/plugins
+
+if [ ! -e "$ADAPTER_TARGET" ] && [ -d "$ADAPTER_TEMPLATE" ]; then
+ cp -a "$ADAPTER_TEMPLATE" "$ADAPTER_TARGET"
+fi
+
+exec python bot.py "$@"
diff --git a/pyproject.toml b/pyproject.toml
index 3ebfed3fa0..dd389eef94 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,7 +19,7 @@ dependencies = [
"jieba>=0.42.1",
"json-repair>=0.47.6",
"maim-message>=0.6.2",
- "maibot-dashboard==1.0.0.dev2026040439",
+ "maibot-dashboard==1.0.1.dev2026050251",
"maibot-plugin-sdk>=2.3.0",
"matplotlib>=3.10.5",
"mcp",
diff --git a/src/chat/message_receive/uni_message_sender.py b/src/chat/message_receive/uni_message_sender.py
index 5a19bc468d..53157909cb 100644
--- a/src/chat/message_receive/uni_message_sender.py
+++ b/src/chat/message_receive/uni_message_sender.py
@@ -36,9 +36,9 @@ def get_webui_chat_broadcaster() -> Tuple[Any, Optional[str], Optional[str]]:
if _webui_chat_broadcaster is None:
try:
from src.webui.routers.chat import WEBUI_CHAT_PLATFORM, chat_manager
- from src.webui.routers.chat.service import WEBUI_CHAT_GROUP_ID
- _webui_chat_broadcaster = (chat_manager, WEBUI_CHAT_PLATFORM, WEBUI_CHAT_GROUP_ID)
+ # 默认不再强制虚拟群聊;WebUI 默认走私聊频道,需要的话由调用者传入虚拟群 ID。
+ _webui_chat_broadcaster = (chat_manager, WEBUI_CHAT_PLATFORM, None)
except ImportError:
_webui_chat_broadcaster = (None, None, None)
return _webui_chat_broadcaster
@@ -98,6 +98,14 @@ async def _send_message(message: SessionMessage, show_log: bool = True) -> bool:
message_type = "rich"
segments = message_segments
+ # 私聊场景下出站消息的 user_info 是机器人自己的身份,
+ # 真正的接收者用户 ID 由 send_service 写入 ``platform_io_target_user_id``。
+ target_user_id = ""
+ additional_config = message.message_info.additional_config or {}
+ raw_target_user_id = additional_config.get("platform_io_target_user_id")
+ if raw_target_user_id:
+ target_user_id = str(raw_target_user_id).strip()
+
await chat_manager.broadcast_to_group(
group_id=group_id or default_group_id or "",
message={
@@ -113,6 +121,7 @@ async def _send_message(message: SessionMessage, show_log: bool = True) -> bool:
"is_bot": True,
},
},
+ user_id=target_user_id,
)
# 注意:机器人消息会由 MessageStorage.store_message 自动保存到数据库
diff --git a/src/webui/config_schema.py b/src/webui/config_schema.py
index 862da1e5e4..1f11faa22f 100644
--- a/src/webui/config_schema.py
+++ b/src/webui/config_schema.py
@@ -70,11 +70,15 @@ def _build_field_schema(
) -> Dict[str, Any]:
field_docs = config_class.get_class_field_docs()
field_type = cls._map_field_type(annotation)
+ raw_description = field_docs.get(field_name, field_info.description or "")
+ # `_wrap_` 标记在配置类 docstring 中表示该说明应作为块级注释(独立成行)
+ # 在前端展示时把它转为换行符,使描述以新行起始或在中间换行
+ description = raw_description.replace("_wrap_", "\n").strip("\n")
schema: Dict[str, Any] = {
"name": field_name,
"type": field_type,
"label": field_name,
- "description": field_docs.get(field_name, field_info.description or ""),
+ "description": description,
"required": field_info.is_required(),
}
diff --git a/src/webui/routers/chat/routes.py b/src/webui/routers/chat/routes.py
index 4098839142..3c03227a4d 100644
--- a/src/webui/routers/chat/routes.py
+++ b/src/webui/routers/chat/routes.py
@@ -13,10 +13,10 @@
from src.webui.dependencies import require_auth
from .service import (
- WEBUI_CHAT_GROUP_ID,
WEBUI_CHAT_PLATFORM,
chat_history,
chat_manager,
+ normalize_webui_user_id,
)
logger = get_logger("webui.chat")
@@ -30,10 +30,15 @@ async def get_chat_history(
user_id: Optional[str] = Query(default=None),
group_id: Optional[str] = Query(default=None),
) -> Dict[str, object]:
- """获取聊天历史记录。"""
- del user_id
- target_group_id = group_id or WEBUI_CHAT_GROUP_ID
- history = chat_history.get_history(limit, target_group_id)
+ """获取聊天历史记录。
+
+ 优先按 ``group_id`` 加载虚拟群聊历史;未提供时使用规范化后的 ``user_id`` 加载 WebUI 私聊历史。
+ """
+ if group_id:
+ history = chat_history.get_history(limit, group_id=group_id)
+ else:
+ normalized_user_id = normalize_webui_user_id(user_id)
+ history = chat_history.get_history(limit, user_id=normalized_user_id)
return {"success": True, "messages": history, "total": len(history)}
@@ -100,10 +105,18 @@ async def get_persons_by_platform(
@router.delete("/history")
async def clear_chat_history(
+ user_id: Optional[str] = Query(default=None),
group_id: Optional[str] = Query(default=None),
) -> Dict[str, object]:
- """清空聊天历史记录。"""
- deleted = chat_history.clear_history(group_id)
+ """清空聊天历史记录。
+
+ 优先按 ``group_id`` 清理虚拟群聊历史;未提供时使用规范化后的 ``user_id`` 清理 WebUI 私聊历史。
+ """
+ if group_id:
+ deleted = chat_history.clear_history(group_id=group_id)
+ else:
+ normalized_user_id = normalize_webui_user_id(user_id)
+ deleted = chat_history.clear_history(user_id=normalized_user_id)
return {"success": True, "message": f"已清空 {deleted} 条聊天记录"}
@@ -113,6 +126,5 @@ async def get_chat_info() -> Dict[str, object]:
return {
"bot_name": global_config.bot.nickname,
"platform": WEBUI_CHAT_PLATFORM,
- "group_id": WEBUI_CHAT_GROUP_ID,
"active_sessions": len(chat_manager.active_connections),
}
diff --git a/src/webui/routers/chat/service.py b/src/webui/routers/chat/service.py
index b8433b92b5..168d31903a 100644
--- a/src/webui/routers/chat/service.py
+++ b/src/webui/routers/chat/service.py
@@ -18,6 +18,8 @@
from src.common.utils.utils_session import SessionUtils
from src.config.config import global_config
+from .serializers import serialize_message_sequence
+
logger = get_logger("webui.chat")
WEBUI_CHAT_GROUP_ID = "webui_local_chat"
@@ -61,7 +63,7 @@ class ChatSessionConnection:
client_session_id: str
user_id: str
user_name: str
- active_group_id: str
+ channel_key: str
virtual_config: Optional[VirtualIdentityConfig]
sender: AsyncMessageSender
@@ -92,6 +94,21 @@ def _message_to_dict(self, msg: SessionMessage, group_id: Optional[str] = None)
user_id = user_info.user_id or ""
is_bot = is_bot_self(msg.platform, user_id)
+ # 将存库中的 raw_message 序列化为前端可识别的富文本消息段,
+ # 避免“刚刚收到的机器人回复是富文本,刷新后变成纯文本”的体验不一致。
+ segments: List[Dict[str, Any]] = []
+ try:
+ raw_message = getattr(msg, "raw_message", None)
+ if raw_message is not None and getattr(raw_message, "components", None):
+ segments = serialize_message_sequence(raw_message)
+ except Exception as exc: # 仅记录警告,退化为纯文本
+ logger.debug(f"序列化历史消息段失败,退化为纯文本: {exc}")
+ segments = []
+
+ is_rich = bool(segments) and not (
+ len(segments) == 1 and segments[0].get("type") == "text"
+ )
+
return {
"id": msg.message_id,
"type": "bot" if is_bot else "user",
@@ -100,32 +117,119 @@ def _message_to_dict(self, msg: SessionMessage, group_id: Optional[str] = None)
"sender_name": user_info.user_nickname or (global_config.bot.nickname if is_bot else "未知用户"),
"sender_id": "bot" if is_bot else user_id,
"is_bot": is_bot,
+ "message_type": "rich" if is_rich else "text",
+ "segments": segments if is_rich else None,
}
- def _resolve_session_id(self, group_id: Optional[str]) -> str:
- """根据群组标识解析聊天会话 ID。
+ def _enrich_reply_segments(
+ self,
+ segments: List[Dict[str, Any]],
+ message_index: Dict[str, SessionMessage],
+ session_id: Optional[str],
+ ) -> None:
+ """回填历史消息中 reply 段缺失的发送者/原内容字段。
+
+ DB 中持久化的 ReplyComponent 通常只保留了 ``target_message_id``,
+ ``target_message_content`` / ``target_message_sender_*`` 字段为空。
+ 这里基于当前会话已加载的消息列表(必要时回查数据库)进行补全。
+
+ Args:
+ segments: 单条历史消息的消息段列表,原地修改。
+ message_index: 当前会话已加载消息的 ``message_id -> SessionMessage`` 索引。
+ session_id: 当前会话 ID,用于按 ID 单查时缩小范围。
+ """
+ for segment in segments:
+ if not isinstance(segment, dict) or segment.get("type") != "reply":
+ continue
+ data = segment.get("data")
+ if not isinstance(data, dict):
+ continue
+ target_message_id = data.get("target_message_id")
+ if not target_message_id:
+ continue
+
+ has_content = bool(str(data.get("target_message_content") or "").strip())
+ has_sender = any(
+ str(data.get(key) or "").strip()
+ for key in (
+ "target_message_sender_id",
+ "target_message_sender_nickname",
+ "target_message_sender_cardname",
+ )
+ )
+ if has_content and has_sender:
+ continue
+
+ target_msg = message_index.get(str(target_message_id))
+ if target_msg is None:
+ # 退化为按 ID 单查(仅当不在当前窗口内时才付出 DB 代价)
+ try:
+ from src.services.message_service import get_message_by_id
+
+ target_msg = get_message_by_id(str(target_message_id), session_id or None)
+ except Exception as exc:
+ logger.debug(f"按 ID 回查 reply 目标消息失败: {exc}")
+ target_msg = None
+ if target_msg is None:
+ continue
+
+ user_info = target_msg.message_info.user_info
+ if not has_content:
+ content_text = (
+ target_msg.processed_plain_text
+ or target_msg.display_message
+ or ""
+ )
+ data["target_message_content"] = content_text
+ if not has_sender:
+ data["target_message_sender_id"] = user_info.user_id or ""
+ data["target_message_sender_nickname"] = user_info.user_nickname or ""
+ data["target_message_sender_cardname"] = (
+ getattr(user_info, "user_cardname", "") or ""
+ )
+
+ def _resolve_session_id(
+ self,
+ group_id: Optional[str] = None,
+ user_id: Optional[str] = None,
+ ) -> Optional[str]:
+ """根据会话标识解析内部聊天会话 ID。
+
+ 优先按虚拟群聊解析;否则按 WebUI 私聊解析。
Args:
- group_id: 群组标识。
+ group_id: 群组标识(虚拟群聊模式)。
+ user_id: 用户标识(私聊模式)。
Returns:
- str: 内部聊天会话 ID。
+ Optional[str]: 内部聊天会话 ID;当 group_id 与 user_id 均未提供时返回 ``None``。
"""
- target_group_id = group_id or WEBUI_CHAT_GROUP_ID
- return SessionUtils.calculate_session_id(WEBUI_CHAT_PLATFORM, group_id=target_group_id)
+ if group_id:
+ return SessionUtils.calculate_session_id(WEBUI_CHAT_PLATFORM, group_id=group_id)
+ if user_id:
+ return SessionUtils.calculate_session_id(WEBUI_CHAT_PLATFORM, user_id=user_id)
+ return None
- def get_history(self, limit: int = 50, group_id: Optional[str] = None) -> List[Dict[str, Any]]:
+ def get_history(
+ self,
+ limit: int = 50,
+ group_id: Optional[str] = None,
+ user_id: Optional[str] = None,
+ ) -> List[Dict[str, Any]]:
"""获取指定会话的历史消息。
Args:
limit: 最大返回条数。
- group_id: 群组标识。
+ group_id: 群组标识(虚拟群聊模式)。
+ user_id: 用户标识(私聊模式)。
Returns:
List[Dict[str, Any]]: 历史消息列表。
"""
- target_group_id = group_id or WEBUI_CHAT_GROUP_ID
- session_id = self._resolve_session_id(target_group_id)
+ session_id = self._resolve_session_id(group_id=group_id, user_id=user_id)
+ if session_id is None:
+ logger.debug("获取聊天历史时缺少 group_id 与 user_id,返回空列表")
+ return []
try:
messages = find_messages(
session_id=session_id,
@@ -133,30 +237,54 @@ def get_history(self, limit: int = 50, group_id: Optional[str] = None) -> List[D
limit_mode="latest",
filter_command=False,
)
- result = [self._message_to_dict(msg, target_group_id) for msg in messages]
- logger.debug(f"从数据库加载了 {len(result)} 条聊天记录 (group_id={target_group_id})")
+ # 构建 message_id -> SessionMessage 索引,用于回填历史中 reply 段的发送者/内容
+ # (DB 中通常只存了 target_message_id,target_message_content/sender_* 缺失)。
+ message_index: Dict[str, SessionMessage] = {}
+ for m in messages:
+ mid = getattr(m, "message_id", None)
+ if mid:
+ message_index[str(mid)] = m
+
+ result: List[Dict[str, Any]] = []
+ for msg in messages:
+ item = self._message_to_dict(msg, group_id)
+ segments = item.get("segments")
+ if segments:
+ self._enrich_reply_segments(segments, message_index, session_id)
+ result.append(item)
+ logger.debug(
+ f"从数据库加载了 {len(result)} 条聊天记录 (group_id={group_id}, user_id={user_id})"
+ )
return result
except Exception as exc:
logger.error(f"从数据库加载聊天记录失败: {exc}")
return []
- def clear_history(self, group_id: Optional[str] = None) -> int:
+ def clear_history(
+ self,
+ group_id: Optional[str] = None,
+ user_id: Optional[str] = None,
+ ) -> int:
"""清空指定会话的历史消息。
Args:
- group_id: 群组标识。
+ group_id: 群组标识(虚拟群聊模式)。
+ user_id: 用户标识(私聊模式)。
Returns:
int: 被删除的消息数量。
"""
- target_group_id = group_id or WEBUI_CHAT_GROUP_ID
- session_id = self._resolve_session_id(target_group_id)
+ session_id = self._resolve_session_id(group_id=group_id, user_id=user_id)
+ if session_id is None:
+ return 0
try:
with get_db_session() as session:
statement = delete(Messages).where(col(Messages.session_id) == session_id)
result = session.exec(statement)
deleted = result.rowcount or 0
- logger.info(f"已清空 {deleted} 条聊天记录 (group_id={target_group_id})")
+ logger.info(
+ f"已清空 {deleted} 条聊天记录 (group_id={group_id}, user_id={user_id})"
+ )
return deleted
except Exception as exc:
logger.error(f"清空聊天记录失败: {exc}")
@@ -174,30 +302,30 @@ def __init__(self) -> None:
self.group_sessions: Dict[str, Set[str]] = {}
self.user_sessions: Dict[str, Set[str]] = {}
- def _bind_group(self, session_id: str, group_id: str) -> None:
- """为会话绑定群组索引。
+ def _bind_channel(self, session_id: str, channel_key: str) -> None:
+ """为会话绑定逻辑频道索引。
Args:
session_id: 内部会话 ID。
- group_id: 群组标识。
+ channel_key: 频道键(``group:`` 或 ``private:``)。
"""
- group_session_ids = self.group_sessions.setdefault(group_id, set())
- group_session_ids.add(session_id)
+ channel_session_ids = self.group_sessions.setdefault(channel_key, set())
+ channel_session_ids.add(session_id)
- def _unbind_group(self, session_id: str, group_id: str) -> None:
- """移除会话与群组的索引关系。
+ def _unbind_channel(self, session_id: str, channel_key: str) -> None:
+ """移除会话与逻辑频道的索引关系。
Args:
session_id: 内部会话 ID。
- group_id: 群组标识。
+ channel_key: 频道键。
"""
- group_session_ids = self.group_sessions.get(group_id)
- if group_session_ids is None:
+ channel_session_ids = self.group_sessions.get(channel_key)
+ if channel_session_ids is None:
return
- group_session_ids.discard(session_id)
- if not group_session_ids:
- del self.group_sessions[group_id]
+ channel_session_ids.discard(session_id)
+ if not channel_session_ids:
+ del self.group_sessions[channel_key]
async def connect(
self,
@@ -220,18 +348,39 @@ async def connect(
virtual_config: 当前虚拟身份配置。
sender: 发送消息到前端的异步回调。
"""
+ channel_key = compute_channel_key(virtual_config, user_id)
existing_session_id = self.client_sessions.get((connection_id, client_session_id))
+ if existing_session_id is not None and existing_session_id == session_id:
+ # 同一物理连接 + 前端会话重复打开(常见于 React StrictMode 双挂载或客户端去抖失败),
+ # 直接复用现有会话并仅刷新可变字段,避免反复断开/重连产生噪声日志。
+ existing = self.active_connections.get(existing_session_id)
+ if existing is not None:
+ if existing.channel_key != channel_key:
+ self._unbind_channel(existing_session_id, existing.channel_key)
+ self._bind_channel(existing_session_id, channel_key)
+ existing.channel_key = channel_key
+ existing.user_id = user_id
+ existing.user_name = user_name
+ existing.virtual_config = virtual_config
+ existing.sender = sender
+ logger.debug(
+ "WebUI 聊天会话复用: session=%s, connection=%s, client_session=%s, channel=%s",
+ session_id,
+ connection_id,
+ client_session_id,
+ channel_key,
+ )
+ return
if existing_session_id is not None:
self.disconnect(existing_session_id)
- active_group_id = get_current_group_id(virtual_config)
session_connection = ChatSessionConnection(
session_id=session_id,
connection_id=connection_id,
client_session_id=client_session_id,
user_id=user_id,
user_name=user_name,
- active_group_id=active_group_id,
+ channel_key=channel_key,
virtual_config=virtual_config,
sender=sender,
)
@@ -240,14 +389,14 @@ async def connect(
self.client_sessions[(connection_id, client_session_id)] = session_id
self.connection_sessions.setdefault(connection_id, set()).add(session_id)
self.user_sessions.setdefault(user_id, set()).add(session_id)
- self._bind_group(session_id, active_group_id)
+ self._bind_channel(session_id, channel_key)
logger.info(
- "WebUI 聊天会话已连接: session=%s, connection=%s, client_session=%s, user=%s, group=%s",
+ "WebUI 聊天会话已连接: session=%s, connection=%s, client_session=%s, user=%s, channel=%s",
session_id,
connection_id,
client_session_id,
user_id,
- active_group_id,
+ channel_key,
)
def disconnect(self, session_id: str) -> None:
@@ -261,7 +410,7 @@ def disconnect(self, session_id: str) -> None:
return
self.client_sessions.pop((session_connection.connection_id, session_connection.client_session_id), None)
- self._unbind_group(session_id, session_connection.active_group_id)
+ self._unbind_channel(session_id, session_connection.channel_key)
connection_session_ids = self.connection_sessions.get(session_connection.connection_id)
if connection_session_ids is not None:
@@ -327,11 +476,11 @@ def update_session_context(
if session_connection is None:
return
- next_group_id = get_current_group_id(virtual_config)
- if next_group_id != session_connection.active_group_id:
- self._unbind_group(session_id, session_connection.active_group_id)
- self._bind_group(session_id, next_group_id)
- session_connection.active_group_id = next_group_id
+ next_channel_key = compute_channel_key(virtual_config, session_connection.user_id)
+ if next_channel_key != session_connection.channel_key:
+ self._unbind_channel(session_id, session_connection.channel_key)
+ self._bind_channel(session_id, next_channel_key)
+ session_connection.channel_key = next_channel_key
session_connection.user_name = user_name
session_connection.virtual_config = virtual_config
@@ -361,16 +510,40 @@ async def broadcast(self, message: Dict[str, Any]) -> None:
for session_id in list(self.active_connections.keys()):
await self.send_message(session_id, message)
- async def broadcast_to_group(self, group_id: str, message: Dict[str, Any]) -> None:
- """向指定群组下的全部逻辑会话广播消息。
+ async def broadcast_to_channel(self, channel_key: str, message: Dict[str, Any]) -> None:
+ """向指定逻辑频道下的全部会话广播消息。
Args:
- group_id: 群组标识。
+ channel_key: 频道键(``group:`` 或 ``private:``)。
message: 待广播的消息内容。
"""
- for session_id in list(self.group_sessions.get(group_id, set())):
+ for session_id in list(self.group_sessions.get(channel_key, set())):
await self.send_message(session_id, message)
+ async def broadcast_to_group(
+ self,
+ group_id: Optional[str],
+ message: Dict[str, Any],
+ *,
+ user_id: Optional[str] = None,
+ ) -> None:
+ """向指定群组或私聊会话广播消息。
+
+ 当 ``group_id`` 非空时按群聊广播;否则按 ``user_id`` 私聊广播。
+
+ Args:
+ group_id: 群组标识;为空时使用 ``user_id``。
+ message: 待广播的消息内容。
+ user_id: 私聊接收方用户 ID。
+ """
+ if group_id:
+ channel_key = f"group:{group_id}"
+ elif user_id:
+ channel_key = f"private:{user_id}"
+ else:
+ return
+ await self.broadcast_to_channel(channel_key, message)
+
chat_history = ChatHistoryManager()
chat_manager = ChatConnectionManager()
@@ -388,6 +561,24 @@ def is_virtual_mode_enabled(virtual_config: Optional[VirtualIdentityConfig]) ->
return bool(virtual_config and virtual_config.enabled)
+def compute_channel_key(virtual_config: Optional[VirtualIdentityConfig], user_id: str) -> str:
+ """计算当前会话的逻辑频道键。
+
+ 虚拟身份启用时使用虚拟群聊 ID,否则使用当前 WebUI 用户 ID 作为私聊频道。
+
+ Args:
+ virtual_config: 虚拟身份配置。
+ user_id: 当前 WebUI 用户 ID。
+
+ Returns:
+ str: 频道键,格式为 ``group:`` 或 ``private:``。
+ """
+ if is_virtual_mode_enabled(virtual_config):
+ assert virtual_config is not None
+ return f"group:{virtual_config.group_id}"
+ return f"private:{user_id}"
+
+
def normalize_webui_user_id(user_id: Optional[str]) -> str:
"""标准化 WebUI 用户 ID。
@@ -500,6 +691,8 @@ def build_session_info_message(
Returns:
Dict[str, Any]: 会话信息消息。
"""
+ # bot_qq 用于前端从 QQ 头像公开接口拉取机器人头像(qq_account == 0 表示未配置,不推送)。
+ bot_qq_account = int(getattr(global_config.bot, "qq_account", 0) or 0)
session_info_data: Dict[str, Any] = {
"type": "session_info",
"session_id": session_id,
@@ -507,6 +700,8 @@ def build_session_info_message(
"user_name": user_name,
"bot_name": global_config.bot.nickname,
}
+ if bot_qq_account > 0:
+ session_info_data["bot_qq"] = str(bot_qq_account)
if is_virtual_mode_enabled(virtual_config):
assert virtual_config is not None
@@ -529,7 +724,7 @@ def get_active_history_group_id(virtual_config: Optional[VirtualIdentityConfig])
virtual_config: 虚拟身份配置。
Returns:
- Optional[str]: 虚拟身份启用时返回对应群组 ID。
+ Optional[str]: 虚拟身份启用时返回对应群组 ID;否则返回 ``None`` 表示使用私聊。
"""
if is_virtual_mode_enabled(virtual_config):
assert virtual_config is not None
@@ -537,16 +732,16 @@ def get_active_history_group_id(virtual_config: Optional[VirtualIdentityConfig])
return None
-def get_current_group_id(virtual_config: Optional[VirtualIdentityConfig]) -> str:
+def get_current_group_id(virtual_config: Optional[VirtualIdentityConfig]) -> Optional[str]:
"""获取当前会话的有效群组 ID。
Args:
virtual_config: 虚拟身份配置。
Returns:
- str: 当前会话应使用的群组 ID。
+ Optional[str]: 虚拟身份启用时返回对应群组 ID;否则返回 ``None``(默认私聊模式)。
"""
- return get_active_history_group_id(virtual_config) or WEBUI_CHAT_GROUP_ID
+ return get_active_history_group_id(virtual_config)
def build_welcome_message(virtual_config: Optional[VirtualIdentityConfig]) -> str:
@@ -611,7 +806,12 @@ async def send_initial_chat_state(
)
history_group_id = get_active_history_group_id(virtual_config)
- history = chat_history.get_history(50, history_group_id)
+ history_user_id = None if history_group_id else user_id
+ history = chat_history.get_history(
+ 50,
+ group_id=history_group_id,
+ user_id=history_user_id,
+ )
await chat_manager.send_message(
session_id,
{
@@ -679,37 +879,42 @@ def create_message_data(
if virtual_config and virtual_config.enabled:
platform = virtual_config.platform or WEBUI_CHAT_PLATFORM
- group_id = virtual_config.group_id or f"{VIRTUAL_GROUP_ID_PREFIX}{uuid.uuid4().hex[:8]}"
- group_name = virtual_config.group_name or "WebUI虚拟群聊"
+ group_id: Optional[str] = (
+ virtual_config.group_id or f"{VIRTUAL_GROUP_ID_PREFIX}{uuid.uuid4().hex[:8]}"
+ )
+ group_name: Optional[str] = virtual_config.group_name or "WebUI虚拟群聊"
actual_user_id = virtual_config.user_id or user_id
- actual_user_name = virtual_config.user_nickname or user_name
+ actual_user_nickname = virtual_config.user_nickname or user_name
else:
platform = WEBUI_CHAT_PLATFORM
- group_id = WEBUI_CHAT_GROUP_ID
- group_name = "WebUI本地聊天室"
+ group_id = None
+ group_name = None
actual_user_id = user_id
- actual_user_name = user_name
-
- return {
- "message_info": {
+ actual_user_nickname = user_name
+
+ message_info: Dict[str, Any] = {
+ "platform": platform,
+ "message_id": message_id,
+ "time": time.time(),
+ "user_info": {
+ "user_id": actual_user_id,
+ "user_nickname": actual_user_nickname,
+ "user_cardname": actual_user_nickname,
"platform": platform,
- "message_id": message_id,
- "time": time.time(),
- "group_info": {
- "group_id": group_id,
- "group_name": group_name,
- "platform": platform,
- },
- "user_info": {
- "user_id": actual_user_id,
- "user_nickname": actual_user_name,
- "user_cardname": actual_user_name,
- "platform": platform,
- },
- "additional_config": {
- "at_bot": is_at_bot,
- },
},
+ "additional_config": {
+ "at_bot": is_at_bot,
+ },
+ }
+ if group_id is not None:
+ message_info["group_info"] = {
+ "group_id": group_id,
+ "group_name": group_name,
+ "platform": platform,
+ }
+
+ return {
+ "message_info": message_info,
"message_segment": {
"type": "seglist",
"data": [
@@ -717,10 +922,6 @@ def create_message_data(
"type": "text",
"data": content,
},
- {
- "type": "mention_bot",
- "data": "1.0",
- },
],
},
"raw_message": content,
@@ -776,6 +977,7 @@ async def handle_chat_message(
},
"virtual_mode": is_virtual_mode_enabled(current_virtual_config),
},
+ user_id=normalized_user_id,
)
message_data = create_message_data(
@@ -788,13 +990,21 @@ async def handle_chat_message(
)
try:
- await chat_manager.broadcast_to_group(target_group_id, {"type": "typing", "is_typing": True})
+ await chat_manager.broadcast_to_group(
+ target_group_id,
+ {"type": "typing", "is_typing": True},
+ user_id=normalized_user_id,
+ )
await chat_bot.message_process(message_data)
except Exception as exc:
logger.error(f"处理消息时出错: {exc}")
await send_chat_error(session_id, f"处理消息时出错: {str(exc)}")
finally:
- await chat_manager.broadcast_to_group(target_group_id, {"type": "typing", "is_typing": False})
+ await chat_manager.broadcast_to_group(
+ target_group_id,
+ {"type": "typing", "is_typing": False},
+ user_id=normalized_user_id,
+ )
return next_user_name
@@ -915,11 +1125,12 @@ async def enable_virtual_identity(
return None
-async def disable_virtual_identity(session_id: str) -> None:
+async def disable_virtual_identity(session_id: str, normalized_user_id: str) -> None:
"""关闭虚拟身份模式。
Args:
session_id: 内部逻辑会话 ID。
+ normalized_user_id: 规范化后的 WebUI 用户 ID,用于加载私聊历史。
"""
await chat_manager.send_message(
session_id,
@@ -933,8 +1144,8 @@ async def disable_virtual_identity(session_id: str) -> None:
session_id,
{
"type": "history",
- "messages": chat_history.get_history(50, WEBUI_CHAT_GROUP_ID),
- "group_id": WEBUI_CHAT_GROUP_ID,
+ "messages": chat_history.get_history(50, user_id=normalized_user_id),
+ "group_id": None,
},
)
await chat_manager.send_message(
@@ -952,6 +1163,7 @@ async def handle_virtual_identity_update(
session_id_prefix: str,
data: Dict[str, Any],
current_virtual_config: Optional[VirtualIdentityConfig],
+ normalized_user_id: str,
) -> Optional[VirtualIdentityConfig]:
"""处理虚拟身份切换请求。
@@ -960,6 +1172,7 @@ async def handle_virtual_identity_update(
session_id_prefix: 会话前缀。
data: 前端提交的数据。
current_virtual_config: 当前虚拟身份配置。
+ normalized_user_id: 规范化后的 WebUI 用户 ID。
Returns:
Optional[VirtualIdentityConfig]: 更新后的虚拟身份配置。
@@ -969,7 +1182,7 @@ async def handle_virtual_identity_update(
next_config = await enable_virtual_identity(session_id, session_id_prefix, virtual_data)
return next_config if next_config is not None else current_virtual_config
- await disable_virtual_identity(session_id)
+ await disable_virtual_identity(session_id, normalized_user_id)
return None
@@ -1019,6 +1232,7 @@ async def dispatch_chat_event(
session_id_prefix=session_id_prefix,
data=data,
current_virtual_config=current_virtual_config,
+ normalized_user_id=normalized_user_id,
)
return current_user_name, next_virtual_config
diff --git a/src/webui/routers/model.py b/src/webui/routers/model.py
index fad701bada..2732383574 100644
--- a/src/webui/routers/model.py
+++ b/src/webui/routers/model.py
@@ -146,6 +146,11 @@ async def _fetch_models_from_provider(
client_config = build_openai_compatible_client_config(provider)
headers.update(client_config.default_headers)
params.update(client_config.default_query)
+ # build_openai_compatible_client_config 在“默认 Bearer”场景下,
+ # 会把 api_key 留在 client_config.api_key 中交给 OpenAI SDK 自行注入 Authorization 头,
+ # 而不会写入 default_headers。这里我们用 httpx 直接发请求,需要手动补上鉴权头/参数。
+ if client_config.api_key and "Authorization" not in headers:
+ headers["Authorization"] = f"Bearer {client_config.api_key}"
try:
async with httpx.AsyncClient(timeout=30.0) as client: