diff --git a/admin-v2/src/pages/Knowledge.tsx b/admin-v2/src/pages/Knowledge.tsx index 07a8496..ddc8ef7 100644 --- a/admin-v2/src/pages/Knowledge.tsx +++ b/admin-v2/src/pages/Knowledge.tsx @@ -19,6 +19,8 @@ import type { ProColumns } from '@ant-design/pro-components' import { ProTable } from '@ant-design/pro-components' import { knowledgeService } 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 { Text, Title } = Typography @@ -708,12 +710,138 @@ export default function Knowledge() { icon: , children: , }, + { + key: 'structured', + label: '结构化数据', + icon: , + children: , + }, ]} /> ) } +// === Structured Data Sources Panel === + +function StructuredSourcesPanel() { + const queryClient = useQueryClient() + const [viewingRows, setViewingRows] = useState(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[] = [ + { title: '名称', dataIndex: 'name', key: 'name', width: 200 }, + { title: '类型', dataIndex: 'source_type', key: 'source_type', width: 120, render: (v: string) => {v} }, + { title: '行数', dataIndex: 'row_count', key: 'row_count', width: 80 }, + { + title: '列', + dataIndex: 'columns', + key: 'columns', + width: 250, + render: (cols: string[]) => ( + + {(cols ?? []).slice(0, 5).map((c) => ( + {c} + ))} + {(cols ?? []).length > 5 && +{(cols as string[]).length - 5}} + + ), + }, + { + 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) => ( + + + deleteMutation.mutate(record.id)}> + + + + ), + }, + ] + + // 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 ( +
+ {viewingRows ? ( + setViewingRows(null)}>返回列表} + > + {rowsLoading ? ( + + ) : rows.length === 0 ? ( + + ) : ( + + )} + + ) : ( + + dataSource={sources} + columns={columns} + loading={isLoading} + rowKey="id" + search={false} + pagination={{ pageSize: 20 }} + toolBarRender={false} + /> + )} + + ) +} + +// === 辅助函数 === + // === 辅助函数 === function flattenCategories(cats: CategoryResponse[]): { id: string; name: string }[] { diff --git a/admin-v2/src/services/knowledge.ts b/admin-v2/src/services/knowledge.ts index a9ed595..884cbc6 100644 --- a/admin-v2/src/services/knowledge.ts +++ b/admin-v2/src/services/knowledge.ts @@ -62,6 +62,33 @@ export interface ListItemsResponse { 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 + created_at: string +} + +export interface StructuredQueryResult { + row_id: string + source_name: string + row_data: Record + score: number +} + // === Service === 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[] }> }) => request.post('/knowledge/items/import', data).then((r) => r.data), + + // === Structured Data Sources === + listStructuredSources: (signal?: AbortSignal) => + request.get('/structured/sources', withSignal({}, signal)) + .then((r) => r.data), + + getStructuredSource: (id: string, signal?: AbortSignal) => + request.get(`/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(`/structured/sources/${sourceId}/rows`, withSignal({}, signal)) + .then((r) => r.data), + + queryStructured: (data: { source_id?: string; query?: string; limit?: number }) => + request.post('/structured/query', data).then((r) => r.data), } diff --git a/crates/zclaw-kernel/src/kernel/mod.rs b/crates/zclaw-kernel/src/kernel/mod.rs index 4798acd..8768b78 100644 --- a/crates/zclaw-kernel/src/kernel/mod.rs +++ b/crates/zclaw-kernel/src/kernel/mod.rs @@ -353,6 +353,14 @@ impl Kernel { 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 if chain.is_empty() { None diff --git a/crates/zclaw-memory/src/store.rs b/crates/zclaw-memory/src/store.rs index ce38a0d..109490d 100644 --- a/crates/zclaw-memory/src/store.rs +++ b/crates/zclaw-memory/src/store.rs @@ -21,6 +21,14 @@ impl MemoryStore { 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 fn ensure_database_dir(database_url: &str) -> Result<()> { // Parse SQLite URL to extract file path diff --git a/crates/zclaw-saas/migrations/20260403000002_webhooks.sql b/crates/zclaw-saas/migrations/20260403000002_webhooks.sql index 3e7a92d..55a6238 100644 --- a/crates/zclaw-saas/migrations/20260403000002_webhooks.sql +++ b/crates/zclaw-saas/migrations/20260403000002_webhooks.sql @@ -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 CREATE TABLE IF NOT EXISTS webhook_subscriptions ( 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_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; + +-- === 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; diff --git a/desktop/src-tauri/src/memory/persistent.rs b/desktop/src-tauri/src/memory/persistent.rs index 36c9869..ce34382 100644 --- a/desktop/src-tauri/src/memory/persistent.rs +++ b/desktop/src-tauri/src/memory/persistent.rs @@ -143,7 +143,11 @@ pub struct PersistentMemoryStore { conn: Arc>, } -#[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 { /// Create a new persistent memory store pub async fn new(app_handle: &tauri::AppHandle) -> Result { @@ -587,7 +591,7 @@ fn sanitize_fts_query(query: &str) -> String { } /// 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 { let uuid_str = Uuid::new_v4().to_string().replace("-", ""); let short_uuid = &uuid_str[..8]; diff --git a/desktop/src/components/ButlerPanel/index.tsx b/desktop/src/components/ButlerPanel/index.tsx index 7a12932..7d9c9c5 100644 --- a/desktop/src/components/ButlerPanel/index.tsx +++ b/desktop/src/components/ButlerPanel/index.tsx @@ -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) { + + {/* Industry section */} + {accountIndustries.length > 0 && ( +
+

+ 行业专长 +

+
+ {accountIndustries.map((item) => { + const config = configs[item.industry_id]; + const keywords = config?.keywords ?? []; + return ( +
+
+ {item.industry_name || item.industry_id} +
+ {keywords.length > 0 && ( +
+ {keywords.slice(0, 8).map((kw) => ( + + {kw} + + ))} + {keywords.length > 8 && ( + +{keywords.length - 8} + )} +
+ )} +
+ ); + })} + {lastSynced && ( +
+ 同步于 {new Date(lastSynced).toLocaleString('zh-CN')} +
+ )} +
+
+ )} ); } diff --git a/desktop/src/components/VikingPanel.tsx b/desktop/src/components/VikingPanel.tsx index 3286d2e..997d569 100644 --- a/desktop/src/components/VikingPanel.tsx +++ b/desktop/src/components/VikingPanel.tsx @@ -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(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([]); + 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() { )} + {/* SaaS Knowledge Base Search */} + {saasReady && ( +
+

+ + 知识库搜索 +

+

+ 搜索 SaaS 端共享知识库 +

+
+ 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" + /> + +
+ {kbResults.length > 0 && ( +
+ {kbResults.map((r) => ( +
+
+ + {r.item_title} + + {r.category_name && ( + + {r.category_name} + + )} + {Math.round(r.score * 100)}% +
+

{r.content}

+ {r.keywords.length > 0 && ( +
+ {r.keywords.slice(0, 5).map((kw) => ( + {kw} + ))} +
+ )} +
+ ))} +
+ )} +
+ )} + {/* Summary Generation */} {status?.available && (
diff --git a/desktop/src/lib/saas-client.ts b/desktop/src/lib/saas-client.ts index 28462b6..bea8d7f 100644 --- a/desktop/src/lib/saas-client.ts +++ b/desktop/src/lib/saas-client.ts @@ -70,6 +70,7 @@ export type { IndustryInfo, IndustryFullConfig, AccountIndustryItem, + KnowledgeSearchResult, } from './saas-types'; export { SaaSApiError } from './saas-errors'; @@ -114,6 +115,7 @@ import { installPromptMethods } from './saas-prompt'; import { installTelemetryMethods } from './saas-telemetry'; import { installBillingMethods } from './saas-billing'; import { installIndustryMethods } from './saas-industry'; +import { installKnowledgeMethods } from './saas-knowledge'; export type { UsageIncrementResult } from './saas-billing'; // Re-export billing types for convenience @@ -448,6 +450,7 @@ installPromptMethods(SaaSClient); installTelemetryMethods(SaaSClient); installBillingMethods(SaaSClient); installIndustryMethods(SaaSClient); +installKnowledgeMethods(SaaSClient); export { installBillingMethods }; // === API Method Type Declarations === @@ -511,6 +514,9 @@ export interface SaaSClient { getIndustryFullConfig(industryId: string): Promise; getMyIndustries(): Promise; getAccountIndustries(accountId: string): Promise; + + // --- Knowledge (saas-knowledge.ts) --- + searchKnowledge(query: string, opts?: { category_id?: string; limit?: number }): Promise; } // === Singleton === diff --git a/desktop/src/lib/saas-knowledge.ts b/desktop/src/lib/saas-knowledge.ts new file mode 100644 index 0000000..4e6b3d9 --- /dev/null +++ b/desktop/src/lib/saas-knowledge.ts @@ -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(method: string, path: string, body?: unknown): Promise }, + query: string, + opts?: { category_id?: string; limit?: number }, + ): Promise { + return this.request('POST', '/api/v1/knowledge/search', { + query, + ...opts, + }); + }; +} diff --git a/desktop/src/lib/saas-types.ts b/desktop/src/lib/saas-types.ts index 7da6658..33e3504 100644 --- a/desktop/src/lib/saas-types.ts +++ b/desktop/src/lib/saas-types.ts @@ -272,6 +272,17 @@ export interface AccountIndustryItem { 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 */ export interface ProviderInfo { id: string;