Files
zclaw_openfang/desktop/src/components/VikingPanel.tsx
iven 9772d6ec94
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(ui): 空catch块添加日志 + ErrorBoundary覆盖高风险组件
空catch块修复 (12处, 6文件):
- ModelsAPI: 4处 localStorage 配置读写添加 console.warn
- VikingPanel: 2处 viking 操作添加日志
- Workspace/MCPServices/SaaSStatus/TOTPSettings: 各1-3处

ErrorBoundary新增覆盖:
- ChatArea: 两种UI模式均包裹(防白屏)
- RightPanel: 两种UI模式均包裹
- AuditLogsPanel/HeartbeatConfig/VikingPanel: 设置页包裹
2026-04-11 00:26:24 +08:00

370 lines
15 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.

/**
* VikingPanel - ZCLAW Semantic Memory UI
*
* Provides interface for semantic search and knowledge base management.
* Uses native Rust SqliteStorage with TF-IDF semantic search.
*/
import { useState, useEffect } from 'react';
import {
Search,
RefreshCw,
AlertCircle,
CheckCircle,
FileText,
Database,
Sparkles,
} from 'lucide-react';
import {
getVikingStatus,
findVikingResources,
listVikingResources,
readVikingResource,
storeWithSummaries,
} from '../lib/viking-client';
import type { VikingStatus, VikingFindResult } from '../lib/viking-client';
export function VikingPanel() {
const [status, setStatus] = useState<VikingStatus | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<VikingFindResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [memoryCount, setMemoryCount] = useState<number | null>(null);
const [expandedUri, setExpandedUri] = useState<string | null>(null);
const [expandedContent, setExpandedContent] = useState<string | null>(null);
const [isLoadingL2, setIsLoadingL2] = useState(false);
const [isGeneratingSummary, setIsGeneratingSummary] = useState(false);
const [summaryUri, setSummaryUri] = useState('');
const [summaryContent, setSummaryContent] = useState('');
const loadStatus = async () => {
setIsLoading(true);
setMessage(null);
try {
const vikingStatus = await getVikingStatus();
setStatus(vikingStatus);
if (vikingStatus.available) {
// Load memory count (use empty path — viking_ls('') returns all, viking_ls('/') returns 0)
try {
const resources = await listVikingResources('');
setMemoryCount(resources.length);
} catch (e) {
console.warn('[VikingPanel] Failed to list resources:', e);
setMemoryCount(null);
}
}
} catch (error) {
console.error('Failed to load Viking status:', error);
setStatus({ available: false, error: String(error) });
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadStatus();
}, []);
const handleSearch = async () => {
if (!searchQuery.trim()) return;
setIsSearching(true);
setMessage(null);
try {
const results = await findVikingResources(searchQuery, undefined, 10);
setSearchResults(results);
if (results.length === 0) {
setMessage({ type: 'error', text: '未找到匹配的资源' });
}
} catch (error) {
setMessage({
type: 'error',
text: `搜索失败: ${error instanceof Error ? error.message : '未知错误'}`,
});
} finally {
setIsSearching(false);
}
};
const handleExpandL2 = async (uri: string) => {
if (expandedUri === uri) {
setExpandedUri(null);
setExpandedContent(null);
return;
}
setExpandedUri(uri);
setIsLoadingL2(true);
try {
const fullContent = await readVikingResource(uri, 'L2');
setExpandedContent(fullContent);
} catch (e) {
console.warn('[VikingPanel] Failed to read resource:', e);
setExpandedContent(null);
} finally {
setIsLoadingL2(false);
}
};
return (
<div className="max-w-4xl">
{/* Header */}
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white"></h1>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
ZCLAW
</p>
</div>
<div className="flex gap-2 items-center">
{status?.available && (
<span className="text-xs flex items-center gap-1 text-green-600">
<CheckCircle className="w-3 h-3" />
</span>
)}
<button
onClick={loadStatus}
disabled={isLoading}
className="text-xs text-white bg-orange-500 hover:bg-orange-600 px-3 py-1.5 rounded-lg flex items-center gap-1 transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-3 h-3 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{/* Status Banner */}
{!status?.available && (
<div className="mb-6 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<div className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-amber-500 mt-0.5" />
<div className="text-xs text-amber-700 dark:text-amber-300">
<p className="font-medium"></p>
<p className="mt-1">
SQLite
</p>
{status?.error && (
<p className="mt-1 text-amber-600 dark:text-amber-400 font-mono text-xs">
{status.error}
</p>
)}
</div>
</div>
</div>
)}
{/* Message */}
{message && (
<div
className={`mb-4 p-3 rounded-lg flex items-center gap-2 ${
message.type === 'success'
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300'
: 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300'
}`}
>
{message.type === 'success' ? (
<CheckCircle className="w-4 h-4" />
) : (
<AlertCircle className="w-4 h-4" />
)}
{message.text}
</div>
)}
{/* Storage Info */}
{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="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-indigo-500 flex items-center justify-center">
<Database className="w-4 h-4 text-white" />
</div>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{status.version || 'Native'} · {status.dataDir || '默认路径'}
</div>
</div>
</div>
<div className="flex gap-4 text-xs">
<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300">
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
<span>SQLite + FTS5</span>
</div>
<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300">
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
<span>TF-IDF </span>
</div>
{memoryCount !== null && (
<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300">
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
<span>{memoryCount} </span>
</div>
)}
</div>
</div>
)}
{/* Search Box */}
{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">
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-3"></h3>
<div className="flex gap-2">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
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-blue-500 focus:border-transparent"
/>
<button
onClick={handleSearch}
disabled={isSearching || !searchQuery.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2 text-sm"
>
{isSearching ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
</button>
</div>
</div>
)}
{/* Search Results */}
{searchResults.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm divide-y divide-gray-100 dark:divide-gray-700">
<div className="p-3 border-b border-gray-200 dark:border-gray-700">
<span className="text-xs text-gray-500">
{searchResults.length}
</span>
</div>
{searchResults.map((result, index) => (
<div key={`${result.uri}-${index}`} className="p-4">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
<FileText className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">
{result.uri}
</span>
<span className={`text-xs px-2 py-0.5 rounded ${
result.level === 'L1'
? 'text-green-600 bg-green-100 dark:bg-green-900/30 dark:text-green-400'
: 'text-gray-400 bg-gray-100 dark:bg-gray-700'
}`}>
{result.level}
</span>
<span className="text-xs text-blue-600 dark:text-blue-400">
{Math.round(result.score * 100)}%
</span>
</div>
<p className="text-xs text-gray-600 dark:text-gray-300 mt-2 line-clamp-3">
{result.content}
</p>
{result.level === 'L1' && (
<button
onClick={() => handleExpandL2(result.uri)}
className="mt-1.5 text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
>
{expandedUri === result.uri ? '收起全文' : '展开全文'}
</button>
)}
{expandedUri === result.uri && (
<div className="mt-2 p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700">
{isLoadingL2 ? (
<div className="flex items-center gap-2 text-xs text-gray-400">
<RefreshCw className="w-3 h-3 animate-spin" /> ...
</div>
) : expandedContent ? (
<p className="text-xs text-gray-600 dark:text-gray-300 whitespace-pre-wrap font-mono">
{expandedContent}
</p>
) : (
<p className="text-xs text-gray-400"></p>
)}
</div>
)}
</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">
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-3"></h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
LLM L0/L1
</p>
<div className="space-y-2">
<input
type="text"
value={summaryUri}
onChange={(e) => setSummaryUri(e.target.value)}
placeholder="资源 URI (如: notes/project-plan)"
className="w-full 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-blue-500 focus:border-transparent"
/>
<textarea
value={summaryContent}
onChange={(e) => setSummaryContent(e.target.value)}
placeholder="资源内容..."
rows={3}
className="w-full 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-blue-500 focus:border-transparent resize-none"
/>
<button
onClick={async () => {
if (!summaryUri.trim() || !summaryContent.trim()) return;
setIsGeneratingSummary(true);
setMessage(null);
try {
await storeWithSummaries(summaryUri, summaryContent);
setMessage({ type: 'success', text: `摘要生成完成: ${summaryUri}` });
setSummaryUri('');
setSummaryContent('');
} catch (error) {
setMessage({
type: 'error',
text: `摘要生成失败: ${error instanceof Error ? error.message : '未知错误'}`,
});
} finally {
setIsGeneratingSummary(false);
}
}}
disabled={isGeneratingSummary || !summaryUri.trim() || !summaryContent.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"
>
{isGeneratingSummary ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Sparkles className="w-4 h-4" />
)}
</button>
</div>
</div>
)}
{/* Info Section */}
<div className="mt-6 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-2"></h3>
<ul className="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<li> SQLite + TF-IDF </li>
<li> </li>
<li> </li>
<li> AI </li>
</ul>
</div>
</div>
);
}