Files
zclaw_openfang/desktop/src/components/ButlerPanel/index.tsx
iven 7db9eb29a0
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
fix(butler): useButlerInsights 使用 resolvedAgentId 查询痛点/方案
审计发现 useButlerInsights 仍使用原始 agentId("1")查询痛点,
而痛点按 kernel UUID 存储导致空结果。改用 effectiveAgentId
(resolvedAgentId ?? agentId)确保查询路径一致。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:29:16 +08:00

187 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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