Files
zclaw_openfang/desktop/src/components/VikingPanel.tsx
iven a0d1392371
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): 5 项 E2E 测试 Bug 修复 — Agent 502 / 错误持久化 / 模型标记 / 侧面板 / 记忆页
- BUG-01: createFromTemplate 在 saas-relay 模式下 try-catch 跳过本地 Kernel
- BUG-02: upsertActiveConversation 持久化前剥离 error/streaming/optimistic 字段
- BUG-04: ModelSelector 添加 available 标记,ChatArea 追踪失败模型 ID
- BUG-05: VikingPanel 移除 status?.available 门控,不可用时 disabled + 重连按钮
- BUG-06: 侧面板 tooltip 改为"查看产物文件",空状态增加图标和说明
2026-04-16 19:12:21 +08:00

481 lines
20 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,
BookOpen,
} from 'lucide-react';
import {
getVikingStatus,
findVikingResources,
listVikingResources,
readVikingResource,
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);
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('');
// 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);
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 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);
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 */}
<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 flex items-center justify-center ${status?.available ? 'bg-gradient-to-br from-blue-500 to-indigo-500' : 'bg-gray-300 dark:bg-gray-600'}`}>
<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?.available
? `${status.version || 'Native'} · ${status.dataDir || '默认路径'}`
: '存储未连接'}
</div>
</div>
{!status?.available && (
<button
onClick={loadStatus}
disabled={isLoading}
className="ml-auto text-xs text-amber-600 dark:text-amber-400 hover:text-amber-700 dark:hover:text-amber-300 flex items-center gap-1 disabled:opacity-50"
>
<RefreshCw className={`w-3 h-3 ${isLoading ? 'animate-spin' : ''}`} />
</button>
)}
</div>
<div className="flex gap-4 text-xs">
<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300">
{status?.available ? (
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
) : (
<AlertCircle className="w-3.5 h-3.5 text-amber-500" />
)}
<span>SQLite + FTS5</span>
</div>
<div className="flex items-center gap-1.5 text-gray-600 dark:text-gray-300">
{status?.available ? (
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
) : (
<AlertCircle className="w-3.5 h-3.5 text-amber-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 */}
<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>
{!status?.available && (
<p className="text-xs text-amber-600 dark:text-amber-400 mb-2 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
</p>
)}
<div className="flex gap-2">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="输入自然语言查询..."
disabled={!status?.available}
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 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<button
onClick={handleSearch}
disabled={isSearching || !searchQuery.trim() || !status?.available}
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>
)}
{/* 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 */}
<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>
{!status?.available && (
<p className="text-xs text-amber-600 dark:text-amber-400 mb-2 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
</p>
)}
<div className="space-y-2">
<input
type="text"
value={summaryUri}
onChange={(e) => setSummaryUri(e.target.value)}
placeholder="资源 URI (如: notes/project-plan)"
disabled={!status?.available}
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 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<textarea
value={summaryContent}
onChange={(e) => setSummaryContent(e.target.value)}
placeholder="资源内容..."
rows={3}
disabled={!status?.available}
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 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<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() || !status?.available}
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>
);
}