fix(v13): V13 审计 6 项修复 — TrajectoryRecorder注册 + industryStore接入 + 知识搜索 + webhook标注 + structured UI + persistent注释

FIX-01: TrajectoryRecorderMiddleware 注册到 create_middleware_chain() (@650优先级)
FIX-02: industryStore 接入 ButlerPanel 行业专长展示 + 自动拉取
FIX-03: 桌面端知识库搜索 saas-knowledge mixin + VikingPanel SaaS KB UI
FIX-04: webhook 迁移标注 deprecated + 添加 down migration 注释
FIX-05: Admin Knowledge 添加结构化数据 Tab (CRUD + 行浏览)
FIX-06: PersistentMemoryStore 精化 dead_code 标注 (完整迁移留后续)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-04-13 01:34:08 +08:00
parent c048cb215f
commit c167ea4ea5
11 changed files with 383 additions and 3 deletions

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useButlerInsights } from '../../hooks/useButlerInsights';
import { useChatStore } from '../../store/chatStore';
import { useIndustryStore } from '../../store/industryStore';
import { InsightsSection } from './InsightsSection';
import { ProposalsSection } from './ProposalsSection';
import { MemorySection } from './MemorySection';
@@ -12,8 +13,16 @@ interface ButlerPanelProps {
export function ButlerPanel({ agentId }: ButlerPanelProps) {
const { painPoints, proposals, loading, error, refresh } = useButlerInsights(agentId);
const messageCount = useChatStore((s) => s.messages.length);
const { accountIndustries, configs, lastSynced, isLoading: industryLoading, fetchIndustries } = useIndustryStore();
const [analyzing, setAnalyzing] = useState(false);
// 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;
@@ -100,6 +109,45 @@ export function ButlerPanel({ agentId }: ButlerPanelProps) {
</h3>
<MemorySection agentId={agentId} />
</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>
);
}

View File

@@ -13,6 +13,7 @@ import {
FileText,
Database,
Sparkles,
BookOpen,
} from 'lucide-react';
import {
getVikingStatus,
@@ -22,6 +23,9 @@ import {
storeWithSummaries,
} from '../lib/viking-client';
import type { VikingStatus, VikingFindResult } from '../lib/viking-client';
import { saasClient } from '../lib/saas-client';
import type { KnowledgeSearchResult } from '../lib/saas-client';
import { useSaaSStore } from '../store/saasStore';
export function VikingPanel() {
const [status, setStatus] = useState<VikingStatus | null>(null);
@@ -38,6 +42,12 @@ export function VikingPanel() {
const [summaryUri, setSummaryUri] = useState('');
const [summaryContent, setSummaryContent] = useState('');
// SaaS knowledge search state
const [kbQuery, setKbQuery] = useState('');
const [kbResults, setKbResults] = useState<KnowledgeSearchResult[]>([]);
const [isKbSearching, setIsKbSearching] = useState(false);
const saasReady = useSaaSStore((s) => s.isLoggedIn);
const loadStatus = async () => {
setIsLoading(true);
setMessage(null);
@@ -88,6 +98,19 @@ export function VikingPanel() {
}
};
const handleKbSearch = async () => {
if (!kbQuery.trim()) return;
setIsKbSearching(true);
try {
const results = await saasClient.searchKnowledge(kbQuery, { limit: 10 });
setKbResults(results);
} catch {
setKbResults([]);
} finally {
setIsKbSearching(false);
}
};
const handleExpandL2 = async (uri: string) => {
if (expandedUri === uri) {
setExpandedUri(null);
@@ -299,6 +322,68 @@ export function VikingPanel() {
</div>
)}
{/* SaaS Knowledge Base Search */}
{saasReady && (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 mb-6 shadow-sm">
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-3 flex items-center gap-2">
<BookOpen className="w-4 h-4 text-indigo-500" />
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
SaaS
</p>
<div className="flex gap-2 mb-3">
<input
type="text"
value={kbQuery}
onChange={(e) => setKbQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleKbSearch()}
placeholder="搜索知识库..."
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
<button
onClick={handleKbSearch}
disabled={isKbSearching || !kbQuery.trim()}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 flex items-center gap-2 text-sm"
>
{isKbSearching ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
</button>
</div>
{kbResults.length > 0 && (
<div className="space-y-2">
{kbResults.map((r) => (
<div key={r.chunk_id} className="p-2.5 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-gray-800 dark:text-gray-200 truncate">
{r.item_title}
</span>
{r.category_name && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400">
{r.category_name}
</span>
)}
<span className="text-[10px] text-blue-500">{Math.round(r.score * 100)}%</span>
</div>
<p className="text-[11px] text-gray-600 dark:text-gray-300 line-clamp-2">{r.content}</p>
{r.keywords.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{r.keywords.slice(0, 5).map((kw) => (
<span key={kw} className="text-[10px] px-1 py-0.5 rounded bg-cyan-50 dark:bg-cyan-900/20 text-cyan-600 dark:text-cyan-400">{kw}</span>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Summary Generation */}
{status?.available && (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 mb-6 shadow-sm">