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

MemorySection.tsx 重写:
- 并行加载 L1 摘要 (viking_read L1) 替代仅显示 URI
- 按记忆类型分组: 偏好/知识/经验/会话
- 折叠/展开每组,默认展开偏好和知识
- 新增用户画像卡片: 行业/角色/沟通风格/近期话题/常用工具
- 数据源: viking_ls + viking_read + agent_get(userProfile)
This commit is contained in:
iven
2026-04-22 18:18:32 +08:00
parent 7afd64f536
commit 52078512a2
2 changed files with 244 additions and 28 deletions

View File

@@ -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>
);
}