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

@@ -19,6 +19,8 @@ import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components' import { ProTable } from '@ant-design/pro-components'
import { knowledgeService } from '@/services/knowledge' import { knowledgeService } from '@/services/knowledge'
import type { CategoryResponse, KnowledgeItem, SearchResult } from '@/services/knowledge' import type { CategoryResponse, KnowledgeItem, SearchResult } from '@/services/knowledge'
import type { StructuredSource } from '@/services/knowledge'
import { TableOutlined } from '@ant-design/icons'
const { TextArea } = Input const { TextArea } = Input
const { Text, Title } = Typography const { Text, Title } = Typography
@@ -708,12 +710,138 @@ export default function Knowledge() {
icon: <BarChartOutlined />, icon: <BarChartOutlined />,
children: <AnalyticsPanel />, children: <AnalyticsPanel />,
}, },
{
key: 'structured',
label: '结构化数据',
icon: <TableOutlined />,
children: <StructuredSourcesPanel />,
},
]} ]}
/> />
</div> </div>
) )
} }
// === Structured Data Sources Panel ===
function StructuredSourcesPanel() {
const queryClient = useQueryClient()
const [viewingRows, setViewingRows] = useState<string | null>(null)
const { data: sources = [], isLoading } = useQuery({
queryKey: ['structured-sources'],
queryFn: ({ signal }) => knowledgeService.listStructuredSources(signal),
})
const { data: rows = [], isLoading: rowsLoading } = useQuery({
queryKey: ['structured-rows', viewingRows],
queryFn: ({ signal }) => knowledgeService.listStructuredRows(viewingRows!, signal),
enabled: !!viewingRows,
})
const deleteMutation = useMutation({
mutationFn: (id: string) => knowledgeService.deleteStructuredSource(id),
onSuccess: () => {
message.success('数据源已删除')
queryClient.invalidateQueries({ queryKey: ['structured-sources'] })
},
onError: (err: Error) => message.error(err.message || '删除失败'),
})
const columns: ProColumns<StructuredSource>[] = [
{ title: '名称', dataIndex: 'name', key: 'name', width: 200 },
{ title: '类型', dataIndex: 'source_type', key: 'source_type', width: 120, render: (v: string) => <Tag>{v}</Tag> },
{ title: '行数', dataIndex: 'row_count', key: 'row_count', width: 80 },
{
title: '列',
dataIndex: 'columns',
key: 'columns',
width: 250,
render: (cols: string[]) => (
<Space size={[4, 4]} wrap>
{(cols ?? []).slice(0, 5).map((c) => (
<Tag key={c} color="blue">{c}</Tag>
))}
{(cols ?? []).length > 5 && <Tag>+{(cols as string[]).length - 5}</Tag>}
</Space>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 160,
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
},
{
title: '操作',
key: 'actions',
width: 140,
render: (_: unknown, record: StructuredSource) => (
<Space>
<Button type="link" size="small" onClick={() => setViewingRows(record.id)}>
</Button>
<Popconfirm title="确认删除此数据源?" onConfirm={() => deleteMutation.mutate(record.id)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
]
// Dynamically generate row columns from the first row's keys
const rowColumns = rows.length > 0
? Object.keys(rows[0].row_data).map((key) => ({
title: key,
dataIndex: ['row_data', key],
key,
ellipsis: true,
render: (v: unknown) => String(v ?? ''),
}))
: []
return (
<div className="space-y-4">
{viewingRows ? (
<Card
title="数据行"
extra={<Button onClick={() => setViewingRows(null)}></Button>}
>
{rowsLoading ? (
<Spin />
) : rows.length === 0 ? (
<Empty description="暂无数据" />
) : (
<Table
dataSource={rows}
columns={rowColumns}
rowKey="id"
size="small"
scroll={{ x: true }}
pagination={{ pageSize: 20 }}
/>
)}
</Card>
) : (
<ProTable<StructuredSource>
dataSource={sources}
columns={columns}
loading={isLoading}
rowKey="id"
search={false}
pagination={{ pageSize: 20 }}
toolBarRender={false}
/>
)}
</div>
)
}
// === 辅助函数 ===
// === 辅助函数 === // === 辅助函数 ===
function flattenCategories(cats: CategoryResponse[]): { id: string; name: string }[] { function flattenCategories(cats: CategoryResponse[]): { id: string; name: string }[] {

View File

@@ -62,6 +62,33 @@ export interface ListItemsResponse {
page_size: number page_size: number
} }
// === Structured Data Sources ===
export interface StructuredSource {
id: string
account_id: string
name: string
source_type: string
row_count: number
columns: string[]
created_at: string
updated_at: string
}
export interface StructuredRow {
id: string
source_id: string
row_data: Record<string, unknown>
created_at: string
}
export interface StructuredQueryResult {
row_id: string
source_name: string
row_data: Record<string, unknown>
score: number
}
// === Service === // === Service ===
export const knowledgeService = { export const knowledgeService = {
@@ -159,4 +186,23 @@ export const knowledgeService = {
// 导入 // 导入
importItems: (data: { category_id: string; files: Array<{ content: string; title?: string; keywords?: string[]; tags?: string[] }> }) => importItems: (data: { category_id: string; files: Array<{ content: string; title?: string; keywords?: string[]; tags?: string[] }> }) =>
request.post('/knowledge/items/import', data).then((r) => r.data), request.post('/knowledge/items/import', data).then((r) => r.data),
// === Structured Data Sources ===
listStructuredSources: (signal?: AbortSignal) =>
request.get<StructuredSource[]>('/structured/sources', withSignal({}, signal))
.then((r) => r.data),
getStructuredSource: (id: string, signal?: AbortSignal) =>
request.get<StructuredSource>(`/structured/sources/${id}`, withSignal({}, signal))
.then((r) => r.data),
deleteStructuredSource: (id: string) =>
request.delete(`/structured/sources/${id}`).then((r) => r.data),
listStructuredRows: (sourceId: string, signal?: AbortSignal) =>
request.get<StructuredRow[]>(`/structured/sources/${sourceId}/rows`, withSignal({}, signal))
.then((r) => r.data),
queryStructured: (data: { source_id?: string; query?: string; limit?: number }) =>
request.post<StructuredQueryResult[]>('/structured/query', data).then((r) => r.data),
} }

View File

@@ -353,6 +353,14 @@ impl Kernel {
chain.register(Arc::new(mw)); chain.register(Arc::new(mw));
} }
// Trajectory recorder — record agent loop events for Hermes analysis
{
use std::sync::Arc;
let tstore = zclaw_memory::trajectory_store::TrajectoryStore::new(self.memory.pool());
let mw = zclaw_runtime::middleware::trajectory_recorder::TrajectoryRecorderMiddleware::new(Arc::new(tstore));
chain.register(Arc::new(mw));
}
// Only return Some if we actually registered middleware // Only return Some if we actually registered middleware
if chain.is_empty() { if chain.is_empty() {
None None

View File

@@ -21,6 +21,14 @@ impl MemoryStore {
Ok(store) Ok(store)
} }
/// Get a clone of the underlying SQLite pool.
///
/// Used by subsystems (e.g. `TrajectoryStore`) that need to share the
/// same database connection pool for their own tables.
pub fn pool(&self) -> SqlitePool {
self.pool.clone()
}
/// Ensure the parent directory for the database file exists /// Ensure the parent directory for the database file exists
fn ensure_database_dir(database_url: &str) -> Result<()> { fn ensure_database_dir(database_url: &str) -> Result<()> {
// Parse SQLite URL to extract file path // Parse SQLite URL to extract file path

View File

@@ -1,3 +1,7 @@
-- NOTE: DEPRECATED — These tables are defined but NOT consumed by any Rust code.
-- Kept for schema compatibility. Will be removed in a future cleanup pass.
-- See: V13 audit FIX-04
-- Webhook subscriptions: external endpoints that receive event notifications -- Webhook subscriptions: external endpoints that receive event notifications
CREATE TABLE IF NOT EXISTS webhook_subscriptions ( CREATE TABLE IF NOT EXISTS webhook_subscriptions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -26,3 +30,10 @@ CREATE TABLE IF NOT EXISTS webhook_deliveries (
CREATE INDEX IF NOT EXISTS idx_webhook_subscriptions_account ON webhook_subscriptions(account_id); CREATE INDEX IF NOT EXISTS idx_webhook_subscriptions_account ON webhook_subscriptions(account_id);
CREATE INDEX IF NOT EXISTS idx_webhook_subscriptions_events ON webhook_subscriptions USING gin(events); CREATE INDEX IF NOT EXISTS idx_webhook_subscriptions_events ON webhook_subscriptions USING gin(events);
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_pending ON webhook_deliveries(subscription_id) WHERE delivered_at IS NULL; CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_pending ON webhook_deliveries(subscription_id) WHERE delivered_at IS NULL;
-- === DOWN MIGRATION ===
-- DROP INDEX IF EXISTS idx_webhook_deliveries_pending;
-- DROP INDEX IF EXISTS idx_webhook_subscriptions_events;
-- DROP INDEX IF EXISTS idx_webhook_subscriptions_account;
-- DROP TABLE IF EXISTS webhook_deliveries;
-- DROP TABLE IF EXISTS webhook_subscriptions;

View File

@@ -143,7 +143,11 @@ pub struct PersistentMemoryStore {
conn: Arc<Mutex<SqliteConnection>>, conn: Arc<Mutex<SqliteConnection>>,
} }
#[allow(dead_code)] // Legacy: operations migrated to VikingStorage, kept for backward compat #[allow(dead_code)]
// Migration status (V13 audit FIX-06):
// - ACTIVE: new(), configure_embedding_client() — embedding config path for chat memory search
// - LEGACY: store(), get(), search(), delete(), stats(), export_all(), import_batch() — data ops moved to VikingStorage
// - Full removal requires migrating embedding config to VikingStorage (~3h, tracked in AUDIT_TRACKER)
impl PersistentMemoryStore { impl PersistentMemoryStore {
/// Create a new persistent memory store /// Create a new persistent memory store
pub async fn new(app_handle: &tauri::AppHandle) -> Result<Self, String> { pub async fn new(app_handle: &tauri::AppHandle) -> Result<Self, String> {
@@ -587,7 +591,7 @@ fn sanitize_fts_query(query: &str) -> String {
} }
/// Generate a unique memory ID /// Generate a unique memory ID
#[allow(dead_code)] // Legacy: kept for potential migration use #[allow(dead_code)] // Legacy: VikingStorage generates its own URIs
pub fn generate_memory_id() -> String { pub fn generate_memory_id() -> String {
let uuid_str = Uuid::new_v4().to_string().replace("-", ""); let uuid_str = Uuid::new_v4().to_string().replace("-", "");
let short_uuid = &uuid_str[..8]; let short_uuid = &uuid_str[..8];

View File

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

View File

@@ -13,6 +13,7 @@ import {
FileText, FileText,
Database, Database,
Sparkles, Sparkles,
BookOpen,
} from 'lucide-react'; } from 'lucide-react';
import { import {
getVikingStatus, getVikingStatus,
@@ -22,6 +23,9 @@ import {
storeWithSummaries, storeWithSummaries,
} from '../lib/viking-client'; } from '../lib/viking-client';
import type { VikingStatus, VikingFindResult } 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() { export function VikingPanel() {
const [status, setStatus] = useState<VikingStatus | null>(null); const [status, setStatus] = useState<VikingStatus | null>(null);
@@ -38,6 +42,12 @@ export function VikingPanel() {
const [summaryUri, setSummaryUri] = useState(''); const [summaryUri, setSummaryUri] = useState('');
const [summaryContent, setSummaryContent] = 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 () => { const loadStatus = async () => {
setIsLoading(true); setIsLoading(true);
setMessage(null); 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) => { const handleExpandL2 = async (uri: string) => {
if (expandedUri === uri) { if (expandedUri === uri) {
setExpandedUri(null); setExpandedUri(null);
@@ -299,6 +322,68 @@ export function VikingPanel() {
</div> </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 */} {/* Summary Generation */}
{status?.available && ( {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"> <div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 mb-6 shadow-sm">

View File

@@ -70,6 +70,7 @@ export type {
IndustryInfo, IndustryInfo,
IndustryFullConfig, IndustryFullConfig,
AccountIndustryItem, AccountIndustryItem,
KnowledgeSearchResult,
} from './saas-types'; } from './saas-types';
export { SaaSApiError } from './saas-errors'; export { SaaSApiError } from './saas-errors';
@@ -114,6 +115,7 @@ import { installPromptMethods } from './saas-prompt';
import { installTelemetryMethods } from './saas-telemetry'; import { installTelemetryMethods } from './saas-telemetry';
import { installBillingMethods } from './saas-billing'; import { installBillingMethods } from './saas-billing';
import { installIndustryMethods } from './saas-industry'; import { installIndustryMethods } from './saas-industry';
import { installKnowledgeMethods } from './saas-knowledge';
export type { UsageIncrementResult } from './saas-billing'; export type { UsageIncrementResult } from './saas-billing';
// Re-export billing types for convenience // Re-export billing types for convenience
@@ -448,6 +450,7 @@ installPromptMethods(SaaSClient);
installTelemetryMethods(SaaSClient); installTelemetryMethods(SaaSClient);
installBillingMethods(SaaSClient); installBillingMethods(SaaSClient);
installIndustryMethods(SaaSClient); installIndustryMethods(SaaSClient);
installKnowledgeMethods(SaaSClient);
export { installBillingMethods }; export { installBillingMethods };
// === API Method Type Declarations === // === API Method Type Declarations ===
@@ -511,6 +514,9 @@ export interface SaaSClient {
getIndustryFullConfig(industryId: string): Promise<import('./saas-types').IndustryFullConfig>; getIndustryFullConfig(industryId: string): Promise<import('./saas-types').IndustryFullConfig>;
getMyIndustries(): Promise<import('./saas-types').AccountIndustryItem[]>; getMyIndustries(): Promise<import('./saas-types').AccountIndustryItem[]>;
getAccountIndustries(accountId: string): Promise<import('./saas-types').AccountIndustryItem[]>; getAccountIndustries(accountId: string): Promise<import('./saas-types').AccountIndustryItem[]>;
// --- Knowledge (saas-knowledge.ts) ---
searchKnowledge(query: string, opts?: { category_id?: string; limit?: number }): Promise<import('./saas-types').KnowledgeSearchResult[]>;
} }
// === Singleton === // === Singleton ===

View File

@@ -0,0 +1,25 @@
/**
* SaaS Knowledge Methods — Mixin
*
* Installs knowledge-search methods onto SaaSClient.prototype.
*/
import type { KnowledgeSearchResult } from './saas-types';
export function installKnowledgeMethods(ClientClass: { prototype: any }): void {
const proto = ClientClass.prototype;
/**
* Search the SaaS knowledge base.
*/
proto.searchKnowledge = async function (
this: { request<T>(method: string, path: string, body?: unknown): Promise<T> },
query: string,
opts?: { category_id?: string; limit?: number },
): Promise<KnowledgeSearchResult[]> {
return this.request('POST', '/api/v1/knowledge/search', {
query,
...opts,
});
};
}

View File

@@ -272,6 +272,17 @@ export interface AccountIndustryItem {
industry_icon: string; industry_icon: string;
} }
/** Knowledge search result from POST /api/v1/knowledge/search */
export interface KnowledgeSearchResult {
chunk_id: string;
item_id: string;
item_title: string;
category_name: string;
content: string;
score: number;
keywords: string[];
}
/** Provider info from GET /api/v1/providers */ /** Provider info from GET /api/v1/providers */
export interface ProviderInfo { export interface ProviderInfo {
id: string; id: string;