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
审计发现 useButlerInsights 仍使用原始 agentId("1")查询痛点,
而痛点按 kernel UUID 存储导致空结果。改用 effectiveAgentId
(resolvedAgentId ?? agentId)确保查询路径一致。
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
187 lines
7.5 KiB
TypeScript
187 lines
7.5 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
||
import { useButlerInsights } from '../../hooks/useButlerInsights';
|
||
import { useChatStore } from '../../store/chatStore';
|
||
import { useIndustryStore } from '../../store/industryStore';
|
||
import { extractAndStoreMemories } from '../../lib/viking-client';
|
||
import { resolveKernelAgentId } from '../../lib/kernel-agent';
|
||
import { InsightsSection } from './InsightsSection';
|
||
import { ProposalsSection } from './ProposalsSection';
|
||
import { MemorySection } from './MemorySection';
|
||
|
||
interface ButlerPanelProps {
|
||
agentId: string | undefined;
|
||
}
|
||
|
||
export function ButlerPanel({ agentId }: ButlerPanelProps) {
|
||
const [resolvedAgentId, setResolvedAgentId] = useState<string | null>(null);
|
||
// Use resolved kernel UUID for queries — raw agentId may be "1" from SaaS relay
|
||
// while pain points/proposals are stored under kernel UUID
|
||
const effectiveAgentId = resolvedAgentId ?? agentId;
|
||
const { painPoints, proposals, loading, error, refresh } = useButlerInsights(effectiveAgentId);
|
||
const messageCount = useChatStore((s) => s.messages.length);
|
||
const { accountIndustries, configs, lastSynced, isLoading: industryLoading, fetchIndustries } = useIndustryStore();
|
||
const [analyzing, setAnalyzing] = useState(false);
|
||
const [memoryRefreshKey, setMemoryRefreshKey] = useState(0);
|
||
|
||
// Resolve SaaS relay agentId ("1") to kernel UUID for VikingStorage queries
|
||
useEffect(() => {
|
||
if (!agentId) {
|
||
setResolvedAgentId(null);
|
||
return;
|
||
}
|
||
resolveKernelAgentId(agentId)
|
||
.then(setResolvedAgentId)
|
||
.catch(() => setResolvedAgentId(agentId));
|
||
}, [agentId]);
|
||
|
||
// Auto-fetch industry configs once per session
|
||
useEffect(() => {
|
||
if (accountIndustries.length === 0 && !industryLoading) {
|
||
fetchIndustries().catch(() => {/* SaaS unavailable — ignore */});
|
||
}
|
||
}, []);
|
||
|
||
const hasData = (painPoints?.length ?? 0) > 0 || (proposals?.length ?? 0) > 0;
|
||
const canAnalyze = messageCount >= 2;
|
||
|
||
const handleAnalyze = useCallback(async () => {
|
||
if (!canAnalyze || analyzing || !resolvedAgentId) return;
|
||
setAnalyzing(true);
|
||
try {
|
||
// 1. Refresh pain points & proposals
|
||
await refresh();
|
||
|
||
// 2. Extract and store memories from current conversation
|
||
const messages = useChatStore.getState().messages;
|
||
if (messages.length >= 2) {
|
||
const extractionMessages = messages.map((m) => ({
|
||
role: m.role as 'user' | 'assistant',
|
||
content: typeof m.content === 'string' ? m.content : '',
|
||
}));
|
||
await extractAndStoreMemories(extractionMessages, resolvedAgentId);
|
||
// Trigger MemorySection to reload
|
||
setMemoryRefreshKey((k) => k + 1);
|
||
}
|
||
} catch {
|
||
// Extraction failure should not block UI — insights still refreshed
|
||
} finally {
|
||
setAnalyzing(false);
|
||
}
|
||
}, [canAnalyze, analyzing, resolvedAgentId, refresh]);
|
||
|
||
if (!agentId) {
|
||
return (
|
||
<div className="flex items-center justify-center h-full">
|
||
<p className="text-sm text-gray-500 dark:text-gray-400">请先选择一个 Agent</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{error && (
|
||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-3 py-2 text-xs text-red-700 dark:text-red-300">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{loading && (
|
||
<div className="flex items-center justify-center py-4">
|
||
<div className="w-5 h-5 border-2 border-gray-300 dark:border-gray-600 border-t-blue-500 rounded-full animate-spin" />
|
||
</div>
|
||
)}
|
||
|
||
{!loading && !hasData && (
|
||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||
<div className="text-gray-400 dark:text-gray-500 mb-3">
|
||
<svg className="w-12 h-12 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||
</svg>
|
||
</div>
|
||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||
管家正在了解您,多轮对话后会自动发现您的需求
|
||
</p>
|
||
<button
|
||
onClick={handleAnalyze}
|
||
disabled={!canAnalyze || analyzing}
|
||
className={`text-xs px-3 py-1.5 rounded-lg transition-colors ${
|
||
canAnalyze && !analyzing
|
||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900/30'
|
||
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 cursor-not-allowed'
|
||
}`}
|
||
>
|
||
{analyzing ? '分析中...' : canAnalyze ? '立即分析对话' : '至少需要 2 条对话才能分析'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{(hasData || loading) && (
|
||
<>
|
||
{/* Insights section */}
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||
我最近在关注
|
||
</h3>
|
||
<InsightsSection painPoints={painPoints} onGenerate={refresh} />
|
||
</div>
|
||
|
||
{/* Proposals section */}
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||
我提出的方案
|
||
</h3>
|
||
<ProposalsSection proposals={proposals} onStatusChange={refresh} />
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Memory section (always show) */}
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||
我记得关于您
|
||
</h3>
|
||
<MemorySection agentId={resolvedAgentId || agentId} refreshKey={memoryRefreshKey} />
|
||
</div>
|
||
|
||
{/* Industry section */}
|
||
{accountIndustries.length > 0 && (
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||
行业专长
|
||
</h3>
|
||
<div className="space-y-2">
|
||
{accountIndustries.map((item) => {
|
||
const config = configs[item.industry_id];
|
||
const keywords = config?.keywords ?? [];
|
||
return (
|
||
<div key={item.industry_id} className="rounded-lg border border-gray-200 dark:border-gray-700 p-2.5">
|
||
<div className="text-xs font-medium text-gray-800 dark:text-gray-200">
|
||
{item.industry_name || item.industry_id}
|
||
</div>
|
||
{keywords.length > 0 && (
|
||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||
{keywords.slice(0, 8).map((kw) => (
|
||
<span key={kw} className="inline-block text-[10px] px-1.5 py-0.5 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400">
|
||
{kw}
|
||
</span>
|
||
))}
|
||
{keywords.length > 8 && (
|
||
<span className="text-[10px] text-gray-400">+{keywords.length - 8}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{lastSynced && (
|
||
<div className="text-[10px] text-gray-400 dark:text-gray-500">
|
||
同步于 {new Date(lastSynced).toLocaleString('zh-CN')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|