feat(desktop): 管家Tab记忆展示增强 — L1摘要+类型分组+用户画像
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
MemorySection.tsx 重写: - 并行加载 L1 摘要 (viking_read L1) 替代仅显示 URI - 按记忆类型分组: 偏好/知识/经验/会话 - 折叠/展开每组,默认展开偏好和知识 - 新增用户画像卡片: 行业/角色/沟通风格/近期话题/常用工具 - 数据源: viking_ls + viking_read + agent_get(userProfile)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Brain, Loader2 } from 'lucide-react';
|
||||
import { listVikingResources } from '../../lib/viking-client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Brain, Loader2, ChevronDown, ChevronRight, User } from 'lucide-react';
|
||||
import { listVikingResources, readVikingResource } from '../../lib/viking-client';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
interface MemorySectionProps {
|
||||
agentId: string;
|
||||
@@ -11,29 +12,140 @@ interface MemoryEntry {
|
||||
uri: string;
|
||||
name: string;
|
||||
resourceType: string;
|
||||
size?: number;
|
||||
modifiedAt?: string;
|
||||
summary?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
type MemoryGroup = 'preferences' | 'knowledge' | 'experience' | 'sessions' | 'other';
|
||||
|
||||
interface UserProfile {
|
||||
industry?: string;
|
||||
role?: string;
|
||||
expertise_level?: string;
|
||||
communication_style?: string;
|
||||
preferred_language?: string;
|
||||
recent_topics?: string[];
|
||||
active_pain_points?: string[];
|
||||
preferred_tools?: string[];
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
const GROUP_LABELS: Record<MemoryGroup, string> = {
|
||||
preferences: '偏好',
|
||||
knowledge: '知识',
|
||||
experience: '经验',
|
||||
sessions: '会话',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
const GROUP_ORDER: MemoryGroup[] = ['preferences', 'knowledge', 'experience', 'sessions', 'other'];
|
||||
|
||||
function classifyGroup(resourceType: string): MemoryGroup {
|
||||
if (resourceType in GROUP_LABELS) return resourceType as MemoryGroup;
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function formatDate(iso?: string): string {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch user profile from agent_get Tauri command
|
||||
async function fetchUserProfile(agentId: string): Promise<UserProfile | null> {
|
||||
try {
|
||||
const result = await invoke<{ userProfile?: UserProfile } | null>('agent_get', { agentId });
|
||||
return result?.userProfile ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function MemorySection({ agentId, refreshKey }: MemorySectionProps) {
|
||||
const [memories, setMemories] = useState<MemoryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<MemoryGroup>>(new Set(['preferences', 'knowledge']));
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [_profileLoading, setProfileLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadMemories = useCallback(async () => {
|
||||
if (!agentId) return;
|
||||
|
||||
setLoading(true);
|
||||
// 查询 agent:// 下的所有记忆资源 (preferences/knowledge/experience/sessions)
|
||||
listVikingResources(`agent://${agentId}/`)
|
||||
.then((entries) => {
|
||||
setMemories(entries as MemoryEntry[]);
|
||||
})
|
||||
.catch(() => {
|
||||
// Memory path may not exist yet — show empty state
|
||||
setMemories([]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [agentId, refreshKey]);
|
||||
try {
|
||||
const entries = await listVikingResources(`agent://${agentId}/`);
|
||||
const typed = entries as MemoryEntry[];
|
||||
|
||||
if (loading) {
|
||||
// Load L1 summaries in parallel (batched to avoid overwhelming)
|
||||
const enriched = await Promise.all(
|
||||
typed.map(async (entry) => {
|
||||
try {
|
||||
const summary = await readVikingResource(entry.uri, 'L1');
|
||||
return { ...entry, summary: summary || entry.name };
|
||||
} catch {
|
||||
return { ...entry, summary: entry.name };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setMemories(enriched);
|
||||
} catch {
|
||||
setMemories([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [agentId]);
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
if (!agentId) return;
|
||||
setProfileLoading(true);
|
||||
try {
|
||||
const p = await fetchUserProfile(agentId);
|
||||
setProfile(p);
|
||||
} catch {
|
||||
setProfile(null);
|
||||
} finally {
|
||||
setProfileLoading(false);
|
||||
}
|
||||
}, [agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMemories();
|
||||
loadProfile();
|
||||
}, [loadMemories, loadProfile, refreshKey]);
|
||||
|
||||
// Group memories by type
|
||||
const grouped = memories.reduce<Record<MemoryGroup, MemoryEntry[]>>((acc, m) => {
|
||||
const group = classifyGroup(m.resourceType);
|
||||
if (!acc[group]) acc[group] = [];
|
||||
acc[group].push(m);
|
||||
return acc;
|
||||
}, {} as Record<MemoryGroup, MemoryEntry[]>);
|
||||
|
||||
const nonEmptyGroups = GROUP_ORDER.filter((g) => (grouped[g]?.length ?? 0) > 0);
|
||||
const totalMemories = memories.length;
|
||||
|
||||
const toggleGroup = (group: MemoryGroup) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(group)) next.delete(group);
|
||||
else next.add(group);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const hasProfile = profile && (
|
||||
profile.industry || profile.role || profile.communication_style ||
|
||||
(profile.recent_topics && profile.recent_topics.length > 0) ||
|
||||
(profile.preferred_tools && profile.preferred_tools.length > 0)
|
||||
);
|
||||
|
||||
if (loading && memories.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 text-gray-400 animate-spin" />
|
||||
@@ -41,7 +153,7 @@ export function MemorySection({ agentId, refreshKey }: MemorySectionProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (memories.length === 0) {
|
||||
if (totalMemories === 0 && !hasProfile) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<Brain className="w-8 h-8 mx-auto mb-2 text-gray-300 dark:text-gray-600" />
|
||||
@@ -54,20 +166,114 @@ export function MemorySection({ agentId, refreshKey }: MemorySectionProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{memories.map((memory) => (
|
||||
<div
|
||||
key={memory.uri}
|
||||
className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-3 py-2"
|
||||
>
|
||||
<div className="text-sm text-gray-900 dark:text-gray-100 truncate">
|
||||
{memory.name}
|
||||
<div className="space-y-3">
|
||||
{/* User Profile Card */}
|
||||
{hasProfile && (
|
||||
<div className="rounded-lg border border-blue-100 dark:border-blue-900/30 bg-blue-50/50 dark:bg-blue-900/10 px-3 py-2.5">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<User className="w-3.5 h-3.5 text-blue-500" />
|
||||
<span className="text-xs font-medium text-blue-700 dark:text-blue-300">用户画像</span>
|
||||
{profile.confidence !== undefined && profile.confidence > 0 && (
|
||||
<span className="text-[10px] text-blue-400 dark:text-blue-500 ml-auto">
|
||||
置信度 {Math.round(profile.confidence * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 truncate mt-0.5">
|
||||
{memory.uri}
|
||||
<div className="space-y-1.5">
|
||||
{profile.industry && (
|
||||
<ProfileField label="行业" value={profile.industry} />
|
||||
)}
|
||||
{profile.role && (
|
||||
<ProfileField label="角色" value={profile.role} />
|
||||
)}
|
||||
{profile.expertise_level && (
|
||||
<ProfileField label="专业水平" value={profile.expertise_level} />
|
||||
)}
|
||||
{profile.communication_style && (
|
||||
<ProfileField label="沟通风格" value={profile.communication_style} />
|
||||
)}
|
||||
{profile.recent_topics && profile.recent_topics.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 items-center">
|
||||
<span className="text-[10px] text-gray-500 dark:text-gray-400 shrink-0">近期话题</span>
|
||||
{profile.recent_topics.slice(0, 8).map((topic) => (
|
||||
<span key={topic} className="inline-block text-[10px] px-1.5 py-0.5 rounded bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-gray-700">
|
||||
{topic}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{profile.preferred_tools && profile.preferred_tools.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 items-center">
|
||||
<span className="text-[10px] text-gray-500 dark:text-gray-400 shrink-0">常用工具</span>
|
||||
{profile.preferred_tools.map((tool) => (
|
||||
<span key={tool} className="inline-block text-[10px] px-1.5 py-0.5 rounded bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400">
|
||||
{tool}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* Memory Groups */}
|
||||
{nonEmptyGroups.map((group) => {
|
||||
const isExpanded = expandedGroups.has(group);
|
||||
const items = grouped[group] ?? [];
|
||||
return (
|
||||
<div key={group}>
|
||||
<button
|
||||
onClick={() => toggleGroup(group)}
|
||||
className="flex items-center gap-1.5 w-full text-left hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded px-1 py-1 transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-gray-400" />
|
||||
)}
|
||||
<span className="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
{GROUP_LABELS[group]}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500">
|
||||
{items.length}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="mt-1 space-y-1.5 pl-1">
|
||||
{items.map((memory) => (
|
||||
<div
|
||||
key={memory.uri}
|
||||
className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/50 px-3 py-2"
|
||||
>
|
||||
<div className="text-xs text-gray-800 dark:text-gray-200 leading-relaxed">
|
||||
{memory.summary || memory.name}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500">
|
||||
{memory.name}
|
||||
</span>
|
||||
{memory.modifiedAt && (
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500 ml-auto">
|
||||
{formatDate(memory.modifiedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileField({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-gray-500 dark:text-gray-400 shrink-0 w-14">{label}</span>
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user