diff --git a/dashboard/src/components/memory/MemoryMiniTabs.tsx b/dashboard/src/components/memory/MemoryMiniTabs.tsx new file mode 100644 index 00000000..ebf253cc --- /dev/null +++ b/dashboard/src/components/memory/MemoryMiniTabs.tsx @@ -0,0 +1,54 @@ +import { TabsList, TabsTrigger } from '@/components/ui/tabs' +import { cn } from '@/lib/utils' + +export interface MemoryMiniTabItem { + value: TValue + label: string + description?: string +} + +export interface MemoryMiniTabsProps { + items: ReadonlyArray> + className?: string + /** 触发器额外样式 */ + triggerClassName?: string +} + +/** + * 长期记忆控制台统一的迷你标签页样式。 + * + * - 复用 shadcn `Tabs` 原语,仅替换样式以保留无障碍能力(`role="tab"` 与文案不变)。 + * - 胶囊形外观,激活态使用主色渐变,便于在密集表单上快速定位当前页签。 + */ +export function MemoryMiniTabs({ + items, + className, + triggerClassName, +}: MemoryMiniTabsProps) { + return ( + + {items.map((item) => ( + + {item.label} + + ))} + + ) +} diff --git a/dashboard/src/components/memory/MemoryProgressIndicator.tsx b/dashboard/src/components/memory/MemoryProgressIndicator.tsx new file mode 100644 index 00000000..c1ab53aa --- /dev/null +++ b/dashboard/src/components/memory/MemoryProgressIndicator.tsx @@ -0,0 +1,130 @@ +import { Loader2 } from 'lucide-react' + +import { Badge } from '@/components/ui/badge' +import { Progress } from '@/components/ui/progress' +import { cn } from '@/lib/utils' + +export interface MemoryProgressIndicatorProps { + /** 0-100 之间的进度百分比 */ + value: number + /** 任务状态文本(如 “运行中”、“已完成”) */ + statusLabel?: string + /** 当前步骤文本(如 “分块中”) */ + stepLabel?: string + /** 状态对应的语义色(用于左侧圆环和徽标) */ + tone?: 'default' | 'success' | 'warning' | 'destructive' | 'muted' + /** 是否显示加载动画(运行中/取消中场景) */ + busy?: boolean + /** 紧凑模式:用于队列列表项 */ + compact?: boolean + /** 额外说明(如 “已完成 36 / 120 分块”) */ + detail?: string + className?: string +} + +const TONE_RING_CLASS: Record, string> = { + default: 'text-primary', + success: 'text-emerald-500', + warning: 'text-amber-500', + destructive: 'text-rose-500', + muted: 'text-muted-foreground', +} + +const TONE_BADGE_VARIANT: Record< + NonNullable, + 'default' | 'secondary' | 'destructive' | 'outline' +> = { + default: 'default', + success: 'secondary', + warning: 'outline', + destructive: 'destructive', + muted: 'outline', +} + +/** + * 长期记忆控制台统一的任务进度展示组件。 + * + * 设计目标: + * - 让用户一眼看清「整体百分比 + 语义状态 + 当前步骤」。 + * - 复用 shadcn `Progress` 与 `Badge`,避免引入额外样式来源。 + * - 在紧凑模式下保留可读性,可放进队列卡片;非紧凑模式带圆环用于详情区。 + */ +export function MemoryProgressIndicator({ + value, + statusLabel, + stepLabel, + tone = 'default', + busy = false, + compact = false, + detail, + className, +}: MemoryProgressIndicatorProps) { + const safeValue = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0 + const ringSize = compact ? 36 : 56 + const ringStroke = compact ? 4 : 5 + const radius = (ringSize - ringStroke) / 2 + const circumference = 2 * Math.PI * radius + const dashOffset = circumference * (1 - safeValue / 100) + + return ( +
+ + +
+
+ {statusLabel ? ( + + {statusLabel} + + ) : null} + {stepLabel ? ( + {stepLabel} + ) : null} + {!compact ? ( + + {safeValue.toFixed(1)}% + + ) : null} +
+ + {detail ?
{detail}
: null} +
+
+ ) +} diff --git a/dashboard/src/lib/memory-progress-client.ts b/dashboard/src/lib/memory-progress-client.ts new file mode 100644 index 00000000..a1c62a80 --- /dev/null +++ b/dashboard/src/lib/memory-progress-client.ts @@ -0,0 +1,99 @@ +import { unifiedWsClient, type WsEventEnvelope } from './unified-ws' + +export type MemoryProgressTopic = 'import_progress' | 'delete_progress' | 'feedback_progress' + +export interface MemoryProgressEvent { + topic: MemoryProgressTopic + event: string + data: Record +} + +type ProgressListener = (event: MemoryProgressEvent) => void + +const DOMAIN = 'memory' +const KNOWN_TOPICS: MemoryProgressTopic[] = ['import_progress', 'delete_progress', 'feedback_progress'] + +/** + * 长期记忆控制台的统一 WebSocket 桥接客户端。 + * + * 负责: + * 1. 订阅 `memory` 域下的若干 topic(导入/删除/反馈进度)。 + * 2. 把后端推送的事件分发给所有已注册的监听器。 + * 3. 即使后端尚未广播也保持安全:监听器为空时不抛错,订阅幂等。 + * + * 与 `pluginProgressClient` 保持一致的形状,便于复用。 + */ +class MemoryProgressClient { + private initialized = false + private listeners: Set = new Set() + private activeTopics: Set = new Set() + + private initialize(): void { + if (this.initialized) { + return + } + + unifiedWsClient.addEventListener((message: WsEventEnvelope) => { + if (message.domain !== DOMAIN) { + return + } + const topic = (message.topic ?? '') as MemoryProgressTopic + if (!KNOWN_TOPICS.includes(topic)) { + return + } + const payload: MemoryProgressEvent = { + topic, + event: message.event, + data: message.data ?? {}, + } + this.listeners.forEach((listener) => { + try { + listener(payload) + } catch (error) { + console.error('长期记忆进度监听器执行失败:', error) + } + }) + }) + + this.initialized = true + } + + async subscribe( + listener: ProgressListener, + topics: MemoryProgressTopic[] = KNOWN_TOPICS, + ): Promise<() => Promise> { + this.initialize() + this.listeners.add(listener) + + // 仅订阅尚未激活的 topic,避免重复 subscribe + for (const topic of topics) { + if (this.activeTopics.has(topic)) { + continue + } + try { + await unifiedWsClient.subscribe(DOMAIN, topic) + this.activeTopics.add(topic) + } catch (error) { + // 后端可能尚未实现该 topic,订阅失败时只记录,不抛出,确保 polling 仍可作为兜底 + console.warn(`订阅长期记忆 topic 失败(将退化到轮询兜底): ${topic}`, error) + } + } + + return async () => { + this.listeners.delete(listener) + if (this.listeners.size === 0) { + const topicsToRelease = Array.from(this.activeTopics) + this.activeTopics.clear() + for (const topic of topicsToRelease) { + try { + await unifiedWsClient.unsubscribe(DOMAIN, topic) + } catch (error) { + console.warn(`取消订阅长期记忆 topic 失败: ${topic}`, error) + } + } + } + } + } +} + +export const memoryProgressClient = new MemoryProgressClient() diff --git a/dashboard/src/routes/auth.tsx b/dashboard/src/routes/auth.tsx index 8c6addea..4b56d821 100644 --- a/dashboard/src/routes/auth.tsx +++ b/dashboard/src/routes/auth.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' @@ -63,23 +63,58 @@ export function AuthPage() { const { t } = useTranslation() const { enableWavesBackground, setEnableWavesBackground } = useAnimation() const { theme, setTheme } = useTheme() + // 避免 React StrictMode 下重复触发 URL token 自动登录。 + const urlTokenHandledRef = useRef(false) - // 如果已经认证,直接跳转到首页 - useEffect(() => { - const verifyAuth = async () => { - try { - const isAuth = await checkAuthStatus() - if (isAuth) { - navigate({ to: '/' }) - } - } catch { - // 忽略错误,保持在登录页 - } finally { - setCheckingAuth(false) + // 从 URL 中提取 token(支持 query 与 hash 两种位置)。 + // 允许 ?token=xxx 、&token=xxx(query)以及 #/foo?token=xxx、#token=xxx(hash)。 + const extractUrlToken = useCallback((): string => { + if (typeof window === 'undefined') return '' + const fromQuery = new URLSearchParams(window.location.search).get('token') + if (fromQuery && fromQuery.trim()) return fromQuery.trim() + // hash 中可能形如 "#token=xxx" 或 "#/path?token=xxx" + const rawHash = window.location.hash.replace(/^#/, '') + if (!rawHash) return '' + const queryIdx = rawHash.indexOf('?') + const hashQuery = queryIdx >= 0 ? rawHash.slice(queryIdx + 1) : rawHash + const fromHash = new URLSearchParams(hashQuery).get('token') + return fromHash && fromHash.trim() ? fromHash.trim() : '' + }, []) + + // 从当前 URL 中移除 token 参数,避免令牌被书签/Referer/浏览器历史泄露。 + const stripTokenFromUrl = useCallback(() => { + if (typeof window === 'undefined') return + try { + const url = new URL(window.location.href) + let changed = false + if (url.searchParams.has('token')) { + url.searchParams.delete('token') + changed = true } + const rawHash = url.hash.replace(/^#/, '') + if (rawHash) { + const queryIdx = rawHash.indexOf('?') + if (queryIdx >= 0) { + const path = rawHash.slice(0, queryIdx) + const hashParams = new URLSearchParams(rawHash.slice(queryIdx + 1)) + if (hashParams.has('token')) { + hashParams.delete('token') + const next = hashParams.toString() + url.hash = next ? `#${path}?${next}` : `#${path}` + changed = true + } + } else if (/^token=/.test(rawHash)) { + url.hash = '' + changed = true + } + } + if (changed) { + window.history.replaceState(null, '', url.toString()) + } + } catch { + // 忽略 URL 解析异常 } - verifyAuth() - }, [navigate]) + }, []) // 获取实际应用的主题(处理 system 情况) const getActualTheme = () => { @@ -97,83 +132,120 @@ export function AuthPage() { setTheme(newTheme) } - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setError('') + const verifyToken = useCallback( + async (rawToken: string): Promise => { + const trimmed = rawToken.trim() + setError('') - if (!token.trim()) { - setError(t('auth.tokenRequired')) - return - } - - setIsValidating(true) - - console.log('开始验证 token...') - - try { - // 向后端发送请求验证 token(后端会设置 HttpOnly Cookie) - const response = await fetch('/api/webui/auth/verify', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', // 确保接收并存储 Cookie - body: JSON.stringify({ token: token.trim() }), - }) - - console.log('Token 验证响应状态:', response.status) - - const result = await parseResponse<{ - valid: boolean - is_first_setup?: boolean - message?: string - }>(response) - - if (!result.success) { - console.error('Token 验证失败:', result.error) - setError(result.error) - return + if (!trimmed) { + setError(t('auth.tokenRequired')) + return false } - const data = result.data - console.log('Token 验证响应数据:', data) + setIsValidating(true) + console.log('开始验证 token...') - if (data.valid) { - console.log('Token 验证成功,准备跳转...') - console.log('is_first_setup:', data.is_first_setup) + try { + // 向后端发送请求验证 token(后端会设置 HttpOnly Cookie) + const response = await fetch('/api/webui/auth/verify', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', // 确保接收并存储 Cookie + body: JSON.stringify({ token: trimmed }), + }) - // Token 验证成功,Cookie 已由后端设置 - // 等待一小段时间确保 Cookie 已设置 - await new Promise((resolve) => setTimeout(resolve, 100)) + console.log('Token 验证响应状态:', response.status) - // 再次检查认证状态 - const authCheck = await checkAuthStatus() - console.log('跳转前认证状态检查:', authCheck) + const result = await parseResponse<{ + valid: boolean + is_first_setup?: boolean + message?: string + }>(response) - // 直接使用验证响应中的 is_first_setup 字段,避免额外请求 - if (data.is_first_setup) { - console.log('跳转到首次配置页面') - // 需要首次配置,跳转到配置向导 - navigate({ to: '/setup' }) - } else { - console.log('跳转到首页') - // 不需要配置或配置已完成,跳转到首页 - navigate({ to: '/' }) + if (!result.success) { + console.error('Token 验证失败:', result.error) + setError(result.error) + return false } - } else { + + const data = result.data + console.log('Token 验证响应数据:', data) + + if (data.valid) { + console.log('Token 验证成功,准备跳转...') + console.log('is_first_setup:', data.is_first_setup) + + // Token 验证成功,Cookie 已由后端设置 + // 等待一小段时间确保 Cookie 已设置 + await new Promise((resolve) => setTimeout(resolve, 100)) + + // 再次检查认证状态 + const authCheck = await checkAuthStatus() + console.log('跳转前认证状态检查:', authCheck) + + // 直接使用验证响应中的 is_first_setup 字段,避免额外请求 + if (data.is_first_setup) { + console.log('跳转到首次配置页面') + navigate({ to: '/setup' }) + } else { + console.log('跳转到首页') + navigate({ to: '/' }) + } + return true + } + console.error('Token 验证失败:', data.message) setError(data.message || t('auth.verifyFailed')) + return false + } catch (err) { + console.error('Token 验证错误:', err) + setError(err instanceof Error ? err.message : t('auth.connFailed')) + return false + } finally { + setIsValidating(false) } - } catch (err) { - console.error('Token 验证错误:', err) - setError( - err instanceof Error ? err.message : t('auth.connFailed') - ) - } finally { - setIsValidating(false) - } + }, + [navigate, t] + ) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + await verifyToken(token) } + // 如果已经认证,直接跳转到首页;否则尝试用 URL 中的 token 自动登录。 + useEffect(() => { + const verifyAuth = async () => { + try { + const isAuth = await checkAuthStatus() + if (isAuth) { + // 已登录场景下,URL 中残留的 token 也清掉,避免外泄。 + stripTokenFromUrl() + navigate({ to: '/' }) + return + } + + // 未登录:检查 URL 是否带 token,带了就自动尝试登录。 + const urlToken = extractUrlToken() + if (urlToken && !urlTokenHandledRef.current) { + urlTokenHandledRef.current = true + // 立即从 URL 中剥离 token,防止刷新/复制链接时再次暴露。 + stripTokenFromUrl() + setToken(urlToken) + // 异步触发验证;失败时错误信息会显示在表单上,token 也会保留在输入框中以便用户修正。 + void verifyToken(urlToken) + } + } catch { + // 忽略错误,保持在登录页 + } finally { + setCheckingAuth(false) + } + } + verifyAuth() + }, [navigate, extractUrlToken, stripTokenFromUrl, verifyToken]) + // 正在检查认证状态时显示加载 if (checkingAuth) { return ( diff --git a/dashboard/src/routes/resource/knowledge-base.tsx b/dashboard/src/routes/resource/knowledge-base.tsx index a4accd75..85ac50fc 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 的配置、自检、导入和检索调优,都在这里! + 在这里完成配置、自检、导入资料和检索调优——一站式管理记忆库

- - @@ -2289,48 +1811,134 @@ export function KnowledgeBasePage() {
-
-
-
- {runtimeBadges.map((item) => ( - - -
-
- {item.label} - {item.value} -
-
+
+
+ {/* 运行时状态条 —— 紧凑、常驻、一眼看完 */} + {runtimeBadges.length > 0 ? ( +
+
+
+ + 运行时状态 +
+ +
+
+ {runtimeBadges.map((item) => ( +
+
+
+
{item.label}
+
+ {item.value} +
+
+ {item.description} +
+
-
{item.description}
- - - ))} + ))} +
+
+ ) : null} + + {/* 快速开始 Hero —— 给新用户明确的"先做什么" */} +
+
+
+
+ 快速开始 +
+

先从这三件事入手

+

+ 不知道该做什么?挑一个最常用的入口,下面的标签页里有更详细的设置。 +

+
+
+ + + +
+
- - - - 概览 - - - 配置 - - - 导入 - - - 调优 - - - 删除 - - - 纠错历史 - - + 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)} /> -
-
- - -
-
- -