feat(ui): add ButlerPanel — pain points, proposals, memory insights
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
- Add Butler types (PainPoint, Proposal, DelegationResult) to viking-client.ts - Add butler API functions: getButlerInsights, getButlerProposals, recordButlerPainPoint, generateButlerSolution, updateButlerProposalStatus, butlerDelegateTask - Create ButlerPanel with three sections: - InsightsSection: pain point cards with evidence chain, severity, confidence - ProposalsSection: solution cards with accept/reject actions - MemorySection: Viking memory entries per agent - Create useButlerInsights hook for data fetching - Add "管家" tab to RightPanel with ConciergeBell icon Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
122
desktop/src/components/ButlerPanel/InsightsSection.tsx
Normal file
122
desktop/src/components/ButlerPanel/InsightsSection.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronUp, AlertTriangle, TrendingUp, Info } from 'lucide-react';
|
||||
import type { ButlerPainPoint } from '../../lib/viking-client';
|
||||
|
||||
interface InsightsSectionProps {
|
||||
painPoints: ButlerPainPoint[];
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: ButlerPainPoint['severity'] }) {
|
||||
const config = {
|
||||
low: { bg: 'bg-blue-100 dark:bg-blue-900/30', text: 'text-blue-700 dark:text-blue-300', label: '低' },
|
||||
medium: { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-700 dark:text-amber-300', label: '中' },
|
||||
high: { bg: 'bg-red-100 dark:bg-red-900/30', text: 'text-red-700 dark:text-red-300', label: '高' },
|
||||
};
|
||||
const c = config[severity];
|
||||
return <span className={`px-1.5 py-0.5 rounded text-xs font-medium ${c.bg} ${c.text}`}>{c.label}</span>;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: ButlerPainPoint['status'] }) {
|
||||
const config = {
|
||||
detected: { bg: 'bg-gray-100 dark:bg-gray-800', text: 'text-gray-600 dark:text-gray-400', label: '已检测' },
|
||||
confirmed: { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-700 dark:text-amber-300', label: '已确认' },
|
||||
solving: { bg: 'bg-blue-100 dark:bg-blue-900/30', text: 'text-blue-700 dark:text-blue-300', label: '解决中' },
|
||||
solved: { bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-700 dark:text-emerald-300', label: '已解决' },
|
||||
dismissed: { bg: 'bg-gray-100 dark:bg-gray-800', text: 'text-gray-500 dark:text-gray-500', label: '已忽略' },
|
||||
};
|
||||
const c = config[status];
|
||||
return <span className={`px-1.5 py-0.5 rounded text-xs ${c.bg} ${c.text}`}>{c.label}</span>;
|
||||
}
|
||||
|
||||
export function InsightsSection({ painPoints }: InsightsSectionProps) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
if (painPoints.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<Info className="w-8 h-8 mx-auto mb-2 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">暂无洞察</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
管家会在对话中自动发现您遇到的反复困难
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{painPoints.map((pp) => {
|
||||
const isExpanded = expandedId === pp.id;
|
||||
return (
|
||||
<div
|
||||
key={pp.id}
|
||||
className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
className="w-full px-3 py-2.5 flex items-start gap-2 text-left hover:bg-gray-50 dark:hover:bg-gray-750"
|
||||
onClick={() => setExpandedId(isExpanded ? null : pp.id)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{pp.summary}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SeverityBadge severity={pp.severity} />
|
||||
<StatusBadge status={pp.status} />
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{pp.occurrence_count} 次
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<TrendingUp className="w-3 h-3 text-gray-400" />
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{Math.round(pp.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 border-t border-gray-100 dark:border-gray-700 pt-2 space-y-2">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
分类: <span className="text-gray-700 dark:text-gray-300">{pp.category}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-gray-600 dark:text-gray-300">证据链:</div>
|
||||
{pp.evidence.map((e, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-xs bg-gray-50 dark:bg-gray-750 rounded p-2"
|
||||
>
|
||||
<div className="text-gray-700 dark:text-gray-300 italic">
|
||||
“{e.user_said}”
|
||||
</div>
|
||||
<div className="text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
{e.why_flagged}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
<span>首次: {new Date(pp.first_seen).toLocaleDateString()}</span>
|
||||
<span>|</span>
|
||||
<span>最近: {new Date(pp.last_seen).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
desktop/src/components/ButlerPanel/MemorySection.tsx
Normal file
71
desktop/src/components/ButlerPanel/MemorySection.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Brain, Loader2 } from 'lucide-react';
|
||||
import { listVikingResources } from '../../lib/viking-client';
|
||||
|
||||
interface MemorySectionProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
interface MemoryEntry {
|
||||
uri: string;
|
||||
name: string;
|
||||
resourceType: string;
|
||||
}
|
||||
|
||||
export function MemorySection({ agentId }: MemorySectionProps) {
|
||||
const [memories, setMemories] = useState<MemoryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId) return;
|
||||
|
||||
setLoading(true);
|
||||
listVikingResources(`viking://agents/${agentId}/memories/`)
|
||||
.then((entries) => {
|
||||
setMemories(entries as MemoryEntry[]);
|
||||
})
|
||||
.catch(() => {
|
||||
// Memory path may not exist yet — show empty state
|
||||
setMemories([]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [agentId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-5 h-5 text-gray-400 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (memories.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<Brain className="w-8 h-8 mx-auto mb-2 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">暂无记忆</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
管家会在对话中自动积累对您的了解
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 truncate mt-0.5">
|
||||
{memory.uri}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
desktop/src/components/ButlerPanel/ProposalsSection.tsx
Normal file
141
desktop/src/components/ButlerPanel/ProposalsSection.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { CheckCircle, XCircle, Clock, Lightbulb, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { ButlerProposal } from '../../lib/viking-client';
|
||||
import { updateButlerProposalStatus } from '../../lib/viking-client';
|
||||
|
||||
interface ProposalsSectionProps {
|
||||
proposals: ButlerProposal[];
|
||||
onStatusChange?: () => void;
|
||||
}
|
||||
|
||||
function ProposalStatusBadge({ status }: { status: ButlerProposal['status'] }) {
|
||||
const config = {
|
||||
pending: { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-700 dark:text-amber-300', icon: Clock, label: '待处理' },
|
||||
accepted: { bg: 'bg-blue-100 dark:bg-blue-900/30', text: 'text-blue-700 dark:text-blue-300', icon: CheckCircle, label: '已接受' },
|
||||
rejected: { bg: 'bg-gray-100 dark:bg-gray-800', text: 'text-gray-500 dark:text-gray-400', icon: XCircle, label: '已拒绝' },
|
||||
completed: { bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-700 dark:text-emerald-300', icon: CheckCircle, label: '已完成' },
|
||||
};
|
||||
const c = config[status];
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium ${c.bg} ${c.text}`}>
|
||||
<Icon className="w-3 h-3" />
|
||||
{c.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProposalsSection({ proposals, onStatusChange }: ProposalsSectionProps) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
|
||||
const handleStatusUpdate = async (proposalId: string, status: string) => {
|
||||
setUpdating(proposalId);
|
||||
try {
|
||||
await updateButlerProposalStatus(proposalId, status);
|
||||
onStatusChange?.();
|
||||
} catch (err) {
|
||||
// Error handled silently - UI will not update
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (proposals.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<Lightbulb className="w-8 h-8 mx-auto mb-2 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">暂无方案</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
当管家发现高置信度痛点时,会自动生成解决方案
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{proposals.map((proposal) => {
|
||||
const isExpanded = expandedId === proposal.id;
|
||||
const isUpdating = updating === proposal.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={proposal.id}
|
||||
className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
className="w-full px-3 py-2.5 flex items-start gap-2 text-left hover:bg-gray-50 dark:hover:bg-gray-750"
|
||||
onClick={() => setExpandedId(isExpanded ? null : proposal.id)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1 truncate">
|
||||
{proposal.title}
|
||||
</div>
|
||||
<ProposalStatusBadge status={proposal.status} />
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 border-t border-gray-100 dark:border-gray-700 pt-2 space-y-3">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 whitespace-pre-line">
|
||||
{proposal.description}
|
||||
</p>
|
||||
|
||||
{proposal.steps.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-gray-600 dark:text-gray-300">步骤:</div>
|
||||
{proposal.steps.map((step) => (
|
||||
<div
|
||||
key={step.index}
|
||||
className="flex items-start gap-2 text-xs"
|
||||
>
|
||||
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 flex items-center justify-center font-medium">
|
||||
{step.index}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-700 dark:text-gray-300">
|
||||
{step.action}
|
||||
</div>
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
{step.detail}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{proposal.status === 'pending' && (
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-md text-xs font-medium bg-emerald-500 text-white hover:bg-emerald-600 disabled:opacity-50"
|
||||
onClick={() => handleStatusUpdate(proposal.id, 'accepted')}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
接受
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-md text-xs font-medium bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||
onClick={() => handleStatusUpdate(proposal.id, 'rejected')}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
拒绝
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
desktop/src/components/ButlerPanel/index.tsx
Normal file
60
desktop/src/components/ButlerPanel/index.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useButlerInsights } from '../../hooks/useButlerInsights';
|
||||
import { InsightsSection } from './InsightsSection';
|
||||
import { ProposalsSection } from './ProposalsSection';
|
||||
import { MemorySection } from './MemorySection';
|
||||
|
||||
interface ButlerPanelProps {
|
||||
agentId: string | undefined;
|
||||
}
|
||||
|
||||
export function ButlerPanel({ agentId }: ButlerPanelProps) {
|
||||
const { painPoints, proposals, loading, error, refresh } = useButlerInsights(agentId);
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Insights section */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
我最近在关注
|
||||
</h3>
|
||||
<InsightsSection painPoints={painPoints} />
|
||||
</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 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
我记得关于您
|
||||
</h3>
|
||||
<MemorySection agentId={agentId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user