fix(production-readiness): 3-batch production readiness cleanup — 12 tasks
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

Batch 1 — User-facing fixes:
- B1-1: Pipeline verified end-to-end (14 Rust commands, 8 frontend invoke, fully connected)
- B1-2: MessageSearch restored to ChatArea with search button in DeerFlow header
- B1-3: Viking cleanup — removed 5 orphan invokes (no Rust impl), added addWithMetadata + storeWithSummaries methods + summary generation UI
- B1-4: api-fallbacks transparency — added _isFallback markers + console.warn to all 6 fallback functions

Batch 2 — System health:
- B2-1: Document drift calibration — TRUTH.md/README.md numbers verified and updated
- B2-2: @reserved annotations on 15 SaaS handler functions with no frontend callers
- B2-3: Scheduled Task Admin V2 — new service + page + route + sidebar navigation
- B2-4: TRUTH.md Pipeline/Viking/ScheduledTask records corrected

Batch 3 — Long-term quality:
- B3-1: hand_run_status/hand_run_list verified as fully implemented (not stubs)
- B3-2: Identity snapshot rollback UI added to RightPanel
- B3-3: P2 code quality — 4 fixes (TODO comments, fire-and-forget notes, design notes, table name validation), 2 verified N/A, 1 upstream
- B3-4: Config PATCH→PUT alignment (admin-v2 config.ts matched to SaaS backend)
This commit is contained in:
iven
2026-04-03 21:34:56 +08:00
parent 305984c982
commit 2ceeeaba3d
17 changed files with 1157 additions and 81 deletions

View File

@@ -9,7 +9,7 @@ import { useAgentStore } from '../store/agentStore';
import { useConfigStore } from '../store/configStore';
import { type UnlistenFn } from '@tauri-apps/api/event';
import { safeListenEvent } from '../lib/safe-tauri';
import { Paperclip, SquarePen, ArrowUp, MessageSquare, Download, X, FileText, Image as ImageIcon } from 'lucide-react';
import { Paperclip, SquarePen, ArrowUp, MessageSquare, Download, X, FileText, Image as ImageIcon, Search } from 'lucide-react';
import { Button, EmptyState, MessageListSkeleton, LoadingDots } from './ui';
import { ResizableChatLayout } from './ai/ResizableChatLayout';
import { ArtifactPanel } from './ai/ArtifactPanel';
@@ -18,7 +18,7 @@ import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/anim
import { FirstConversationPrompt } from './FirstConversationPrompt';
import { ClassroomPlayer } from './classroom_player';
import { useClassroomStore } from '../store/classroomStore';
// MessageSearch temporarily removed during DeerFlow redesign
import { MessageSearch } from './MessageSearch';
import { OfflineIndicator } from './OfflineIndicator';
import {
useVirtualizedMessages,
@@ -67,6 +67,7 @@ export function ChatArea() {
const [input, setInput] = useState('');
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
@@ -325,6 +326,17 @@ export function ChatArea() {
);
})()}
<OfflineIndicator compact />
{messages.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => setSearchOpen((prev) => !prev)}
className={`flex items-center gap-1 rounded-lg transition-colors ${searchOpen ? 'text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800'}`}
title="搜索消息"
>
<Search className="w-3.5 h-3.5" />
</Button>
)}
{messages.length > 0 && (
<Button
variant="ghost"
@@ -352,6 +364,27 @@ export function ChatArea() {
</div>
</div>
{/* MessageSearch panel */}
<AnimatePresence>
{searchOpen && messages.length > 0 && (
<motion.div
key="message-search"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="border-b border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/50 overflow-hidden"
>
<div className="px-6 py-3 max-w-4xl mx-auto">
<MessageSearch onNavigateToMessage={(id) => {
const el = messageRefs.current.get(id);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}} />
</div>
</motion.div>
)}
</AnimatePresence>
{/* Messages */}
<Conversation className="flex-1 bg-white dark:bg-gray-900">
<AnimatePresence mode="popLayout">

View File

@@ -1,4 +1,4 @@
import { ReactNode, useEffect, useMemo, useState } from 'react';
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { motion } from 'framer-motion';
import { getStoredGatewayUrl } from '../lib/gateway-client';
import { useConnectionStore } from '../store/connectionStore';
@@ -6,10 +6,12 @@ import { useAgentStore, type PluginStatus } from '../store/agentStore';
import { useConfigStore } from '../store/configStore';
import { toChatAgent, useChatStore, type CodeBlock } from '../store/chatStore';
import { useConversationStore } from '../store/chat/conversationStore';
import { intelligenceClient, type IdentitySnapshot } from '../lib/intelligence-client';
import {
Wifi, WifiOff, Bot, BarChart3, Plug, RefreshCw,
MessageSquare, Cpu, FileText, User, Activity, Brain,
Shield, Sparkles, List, Network, Dna
Shield, Sparkles, List, Network, Dna, History,
ChevronDown, ChevronUp, RotateCcw, AlertCircle, Loader2,
} from 'lucide-react';
// === Helper to extract code blocks from markdown content ===
@@ -109,6 +111,14 @@ export function RightPanel() {
const [isEditingAgent, setIsEditingAgent] = useState(false);
const [agentDraft, setAgentDraft] = useState<AgentDraft | null>(null);
// Identity snapshot state
const [snapshots, setSnapshots] = useState<IdentitySnapshot[]>([]);
const [snapshotsExpanded, setSnapshotsExpanded] = useState(false);
const [snapshotsLoading, setSnapshotsLoading] = useState(false);
const [snapshotsError, setSnapshotsError] = useState<string | null>(null);
const [restoringSnapshotId, setRestoringSnapshotId] = useState<string | null>(null);
const [confirmRestoreId, setConfirmRestoreId] = useState<string | null>(null);
const connected = connectionState === 'connected';
const selectedClone = useMemo(
() => clones.find((clone) => clone.id === currentAgent?.id),
@@ -170,6 +180,46 @@ export function RightPanel() {
}
};
const loadSnapshots = useCallback(async () => {
const agentId = currentAgent?.id;
if (!agentId) return;
setSnapshotsLoading(true);
setSnapshotsError(null);
try {
const result = await intelligenceClient.identity.getSnapshots(agentId, 20);
setSnapshots(result);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setSnapshotsError(`加载快照失败: ${msg}`);
} finally {
setSnapshotsLoading(false);
}
}, [currentAgent?.id]);
const handleRestoreSnapshot = useCallback(async (snapshotId: string) => {
const agentId = currentAgent?.id;
if (!agentId) return;
setRestoringSnapshotId(snapshotId);
setSnapshotsError(null);
setConfirmRestoreId(null);
try {
await intelligenceClient.identity.restoreSnapshot(agentId, snapshotId);
await loadSnapshots();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setSnapshotsError(`回滚失败: ${msg}`);
} finally {
setRestoringSnapshotId(null);
}
}, [currentAgent?.id, loadSnapshots]);
// Load snapshots when agent tab is active and agent changes
useEffect(() => {
if (activeTab === 'agent' && currentAgent?.id) {
loadSnapshots();
}
}, [activeTab, currentAgent?.id, loadSnapshots]);
const userMsgCount = messages.filter(m => m.role === 'user').length;
const assistantMsgCount = messages.filter(m => m.role === 'assistant').length;
const toolCallCount = messages.filter(m => m.role === 'tool').length;
@@ -479,6 +529,118 @@ export function RightPanel() {
)}
</div>
</motion.div>
{/* 历史快照 */}
<motion.div
whileHover={cardHover}
transition={defaultTransition}
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 shadow-sm"
>
<button
type="button"
className="w-full flex items-center justify-between mb-0"
onClick={() => setSnapshotsExpanded(!snapshotsExpanded)}
>
<div className="flex items-center gap-2">
<History className="w-4 h-4 text-gray-500 dark:text-gray-400" />
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100"></span>
{snapshots.length > 0 && (
<Badge variant="default" className="text-xs">{snapshots.length}</Badge>
)}
</div>
{snapshotsExpanded ? (
<ChevronUp className="w-4 h-4 text-gray-400" />
) : (
<ChevronDown className="w-4 h-4 text-gray-400" />
)}
</button>
{snapshotsExpanded && (
<div className="mt-3 space-y-2">
{snapshotsError && (
<div className="flex items-center gap-2 p-2 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-xs">
<AlertCircle className="w-3.5 h-3.5 flex-shrink-0" />
<span>{snapshotsError}</span>
</div>
)}
{snapshotsLoading ? (
<div className="flex items-center justify-center py-4 text-gray-500 dark:text-gray-400 text-xs">
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</div>
) : snapshots.length === 0 ? (
<div className="text-center py-4 text-gray-500 dark:text-gray-400 text-xs bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-100 dark:border-gray-700">
</div>
) : (
snapshots.map((snap) => {
const isRestoring = restoringSnapshotId === snap.id;
const isConfirming = confirmRestoreId === snap.id;
const timeLabel = formatSnapshotTime(snap.timestamp);
return (
<div
key={snap.id}
className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-800/50 border border-gray-100 dark:border-gray-700"
>
<div className="w-7 h-7 rounded-md bg-gray-200 dark:bg-gray-700 flex items-center justify-center flex-shrink-0 mt-0.5">
<History className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400">{timeLabel}</span>
{isConfirming ? (
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmRestoreId(null)}
disabled={isRestoring}
className="text-xs px-2 py-0.5 h-auto"
>
</Button>
<Button
variant="primary"
size="sm"
onClick={() => handleRestoreSnapshot(snap.id)}
disabled={isRestoring}
className="text-xs px-2 py-0.5 h-auto bg-orange-500 hover:bg-orange-600"
>
{isRestoring ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<RotateCcw className="w-3 h-3 mr-1" />
)}
</Button>
</div>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmRestoreId(snap.id)}
disabled={restoringSnapshotId !== null}
className="text-xs text-gray-500 hover:text-orange-600 px-2 py-0.5 h-auto"
title="回滚到此版本"
>
<RotateCcw className="w-3 h-3 mr-1" />
</Button>
)}
</div>
<p className="text-sm text-gray-700 dark:text-gray-300 mt-1 truncate" title={snap.reason}>
{snap.reason || '自动快照'}
</p>
</div>
</div>
);
})
)}
</div>
)}
</motion.div>
</div>
) : activeTab === 'files' ? (
<div className="p-4">
@@ -791,3 +953,15 @@ function AgentToggle({
</label>
);
}
function formatSnapshotTime(timestamp: string): string {
const now = Date.now();
const then = new Date(timestamp).getTime();
const diff = now - then;
if (diff < 60000) return '刚刚';
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`;
if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`;
return new Date(timestamp).toLocaleDateString('zh-CN');
}

View File

@@ -12,12 +12,14 @@ import {
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';
@@ -32,6 +34,9 @@ export function VikingPanel() {
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);
@@ -292,6 +297,61 @@ export function VikingPanel() {
</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>

View File

@@ -69,6 +69,7 @@ export interface SecurityLayerFallback {
}
export interface SecurityStatusFallback {
_isFallback?: true;
layers: SecurityLayerFallback[];
enabledCount: number;
totalCount: number;
@@ -107,8 +108,10 @@ interface TriggerForTasks {
* Default quick config when /api/config/quick returns 404.
* Uses sensible defaults for a new user experience.
*/
export function getQuickConfigFallback(): QuickConfigFallback {
export function getQuickConfigFallback(): QuickConfigFallback & { _isFallback: true } {
console.warn('[fallback] 使用降级数据: getQuickConfigFallback');
return {
_isFallback: true as const,
agentName: '默认助手',
agentRole: 'AI 助手',
userName: '用户',
@@ -127,13 +130,15 @@ export function getQuickConfigFallback(): QuickConfigFallback {
* Default workspace info when /api/workspace returns 404.
* Returns a placeholder indicating workspace is not configured.
*/
export function getWorkspaceInfoFallback(): WorkspaceInfoFallback {
export function getWorkspaceInfoFallback(): WorkspaceInfoFallback & { _isFallback: true } {
console.warn('[fallback] 使用降级数据: getWorkspaceInfoFallback');
// Try to get a reasonable default path
const defaultPath = typeof window !== 'undefined'
? `${navigator.userAgent.includes('Windows') ? 'C:\\Users' : '/home'}/workspace`
: '/workspace';
return {
_isFallback: true as const,
path: defaultPath,
resolvedPath: defaultPath,
exists: false,
@@ -145,7 +150,8 @@ export function getWorkspaceInfoFallback(): WorkspaceInfoFallback {
/**
* Calculate usage stats from session data when /api/stats/usage returns 404.
*/
export function getUsageStatsFallback(sessions: SessionForStats[] = []): UsageStatsFallback {
export function getUsageStatsFallback(sessions: SessionForStats[] = []): UsageStatsFallback & { _isFallback: true } {
console.warn('[fallback] 使用降级数据: getUsageStatsFallback — 基于本地 session 数据计算');
const stats: UsageStatsFallback = {
totalSessions: sessions.length,
totalMessages: 0,
@@ -173,14 +179,15 @@ export function getUsageStatsFallback(sessions: SessionForStats[] = []): UsageSt
}
}
return stats;
return { ...stats, _isFallback: true as const };
}
/**
* Convert skills to plugin status when /api/plugins/status returns 404.
* ZCLAW uses Skills instead of traditional plugins.
*/
export function getPluginStatusFallback(skills: SkillForPlugins[] = []): PluginStatusFallback[] {
export function getPluginStatusFallback(skills: SkillForPlugins[] = []): Array<PluginStatusFallback & { _isFallback?: true }> {
console.warn('[fallback] 使用降级数据: getPluginStatusFallback — 从 Skills 列表推断');
if (skills.length === 0) {
// No skills loaded — return empty rather than fabricating fake builtins
return [];
@@ -197,7 +204,8 @@ export function getPluginStatusFallback(skills: SkillForPlugins[] = []): PluginS
/**
* Convert triggers to scheduled tasks when /api/scheduler/tasks returns 404.
*/
export function getScheduledTasksFallback(triggers: TriggerForTasks[] = []): ScheduledTaskFallback[] {
export function getScheduledTasksFallback(triggers: TriggerForTasks[] = []): Array<ScheduledTaskFallback & { _isFallback?: true }> {
console.warn('[fallback] 使用降级数据: getScheduledTasksFallback — 从 Triggers 列表推断');
return triggers
.filter((t) => t.enabled)
.map((trigger) => ({
@@ -214,7 +222,8 @@ export function getScheduledTasksFallback(triggers: TriggerForTasks[] = []): Sch
* Returns honest minimal response — only includes layers that correspond
* to real ZCLAW capabilities, no fabricated layers.
*/
export function getSecurityStatusFallback(): SecurityStatusFallback {
export function getSecurityStatusFallback(): SecurityStatusFallback & { _isFallback: true } {
console.warn('[fallback] 使用降级数据: getSecurityStatusFallback — 返回静态安全层状态');
const layers: SecurityLayerFallback[] = [
{ name: 'device_auth', enabled: true, description: '设备认证' },
{ name: 'rbac', enabled: true, description: '角色权限控制' },
@@ -228,6 +237,7 @@ export function getSecurityStatusFallback(): SecurityStatusFallback {
const securityLevel = calculateSecurityLevel(enabledCount, layers.length);
return {
_isFallback: true as const,
layers,
enabledCount,
totalCount: layers.length,

View File

@@ -65,13 +65,15 @@ export async function addVikingResource(
}
/**
* Add a resource with inline content
* Add a resource with metadata (keywords + importance)
*/
export async function addVikingResourceInline(
export async function addVikingResourceWithMetadata(
uri: string,
content: string
content: string,
keywords: string[],
importance?: number
): Promise<VikingAddResult> {
return invoke<VikingAddResult>('viking_add_inline', { uri, content });
return invoke<VikingAddResult>('viking_add_with_metadata', { uri, content, keywords, importance });
}
/**
@@ -136,41 +138,16 @@ export async function getVikingTree(
return invoke<Record<string, unknown>>('viking_tree', { path, depth });
}
// === Server Functions ===
export interface VikingServerStatus {
running: boolean;
port?: number;
pid?: number;
error?: string;
}
// === Summary Generation Functions ===
/**
* Get Viking server status
* Store a resource and auto-generate L0/L1 summaries via configured LLM driver
*/
export async function getVikingServerStatus(): Promise<VikingServerStatus> {
return invoke<VikingServerStatus>('viking_server_status');
}
/**
* Start Viking server
*/
export async function startVikingServer(): Promise<void> {
return invoke<void>('viking_server_start');
}
/**
* Stop Viking server
*/
export async function stopVikingServer(): Promise<void> {
return invoke<void>('viking_server_stop');
}
/**
* Restart Viking server
*/
export async function restartVikingServer(): Promise<void> {
return invoke<void>('viking_server_restart');
export async function storeWithSummaries(
uri: string,
content: string
): Promise<VikingAddResult> {
return invoke<VikingAddResult>('viking_store_with_summaries', { uri, content });
}
// === Memory Extraction Functions ===