feat: 实现循环防护和安全验证功能
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
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
refactor(loop_guard): 为LoopGuard添加Clone派生 feat(capabilities): 实现CapabilityManager.validate()安全验证 fix(agentStore): 添加token用量追踪 chore: 删除未实现的Predictor/Lead HAND.toml文件 style(Credits): 移除假数据并标注开发中状态 refactor(Skills): 动态加载技能卡片 perf(configStore): 为定时任务添加localStorage降级 docs: 更新功能文档和版本变更记录
This commit is contained in:
@@ -212,6 +212,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
loadUsageStats: async () => {
|
||||
try {
|
||||
const { conversations } = useChatStore.getState();
|
||||
const tokenData = useChatStore.getState().getTotalTokens();
|
||||
|
||||
let totalMessages = 0;
|
||||
for (const conversation of conversations) {
|
||||
@@ -225,7 +226,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
const stats: UsageStats = {
|
||||
totalSessions: conversations.length,
|
||||
totalMessages,
|
||||
totalTokens: 0,
|
||||
totalTokens: tokenData.total,
|
||||
byModel: {},
|
||||
};
|
||||
|
||||
|
||||
@@ -85,6 +85,9 @@ interface ChatState {
|
||||
isLoading: boolean;
|
||||
currentModel: string;
|
||||
sessionKey: string | null;
|
||||
// Token usage tracking
|
||||
totalInputTokens: number;
|
||||
totalOutputTokens: number;
|
||||
|
||||
addMessage: (message: Message) => void;
|
||||
updateMessage: (id: string, updates: Partial<Message>) => void;
|
||||
@@ -97,6 +100,8 @@ interface ChatState {
|
||||
newConversation: () => void;
|
||||
switchConversation: (id: string) => void;
|
||||
deleteConversation: (id: string) => void;
|
||||
addTokenUsage: (inputTokens: number, outputTokens: number) => void;
|
||||
getTotalTokens: () => { input: number; output: number; total: number };
|
||||
searchSkills: (query: string) => { results: Array<{ id: string; name: string; description: string }>; totalAvailable: number };
|
||||
}
|
||||
|
||||
@@ -194,8 +199,10 @@ export const useChatStore = create<ChatState>()(
|
||||
isLoading: false,
|
||||
currentModel: 'glm-4-flash',
|
||||
sessionKey: null,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
|
||||
addMessage: (message) =>
|
||||
addMessage: (message: Message) =>
|
||||
set((state) => ({ messages: [...state.messages, message] })),
|
||||
|
||||
updateMessage: (id, updates) =>
|
||||
@@ -432,7 +439,7 @@ export const useChatStore = create<ChatState>()(
|
||||
};
|
||||
set((state) => ({ messages: [...state.messages, handMsg] }));
|
||||
},
|
||||
onComplete: () => {
|
||||
onComplete: (inputTokens?: number, outputTokens?: number) => {
|
||||
const state = get();
|
||||
|
||||
// Save conversation to persist across refresh
|
||||
@@ -448,6 +455,11 @@ export const useChatStore = create<ChatState>()(
|
||||
),
|
||||
});
|
||||
|
||||
// Track token usage if provided (KernelClient provides these)
|
||||
if (inputTokens !== undefined && outputTokens !== undefined) {
|
||||
get().addTokenUsage(inputTokens, outputTokens);
|
||||
}
|
||||
|
||||
// Async memory extraction after stream completes
|
||||
const msgs = get().messages
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
@@ -518,6 +530,17 @@ export const useChatStore = create<ChatState>()(
|
||||
}
|
||||
},
|
||||
|
||||
addTokenUsage: (inputTokens: number, outputTokens: number) =>
|
||||
set((state) => ({
|
||||
totalInputTokens: state.totalInputTokens + inputTokens,
|
||||
totalOutputTokens: state.totalOutputTokens + outputTokens,
|
||||
})),
|
||||
|
||||
getTotalTokens: () => {
|
||||
const { totalInputTokens, totalOutputTokens } = get();
|
||||
return { input: totalInputTokens, output: totalOutputTokens, total: totalInputTokens + totalOutputTokens };
|
||||
},
|
||||
|
||||
searchSkills: (query: string) => {
|
||||
const discovery = getSkillDiscovery();
|
||||
const result = discovery.searchSkills(query);
|
||||
|
||||
@@ -395,9 +395,18 @@ export const useConfigStore = create<ConfigStateSlice & ConfigActionsSlice>((set
|
||||
|
||||
try {
|
||||
const result = await client.listScheduledTasks();
|
||||
set({ scheduledTasks: result?.tasks || [] });
|
||||
const tasks = result?.tasks || [];
|
||||
set({ scheduledTasks: tasks });
|
||||
// Persist to localStorage as fallback
|
||||
try { localStorage.setItem('zclaw-scheduled-tasks', JSON.stringify(tasks)); } catch { /* ignore */ }
|
||||
} catch {
|
||||
// Ignore if heartbeat.tasks not available
|
||||
// Fallback: load from localStorage
|
||||
try {
|
||||
const stored = localStorage.getItem('zclaw-scheduled-tasks');
|
||||
if (stored) {
|
||||
set({ scheduledTasks: JSON.parse(stored) });
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -416,9 +425,11 @@ export const useConfigStore = create<ConfigStateSlice & ConfigActionsSlice>((set
|
||||
nextRun: result.nextRun,
|
||||
description: result.description,
|
||||
};
|
||||
set((state) => ({
|
||||
scheduledTasks: [...state.scheduledTasks, newTask],
|
||||
}));
|
||||
set((state) => {
|
||||
const tasks = [...state.scheduledTasks, newTask];
|
||||
try { localStorage.setItem('zclaw-scheduled-tasks', JSON.stringify(tasks)); } catch { /* ignore */ }
|
||||
return { scheduledTasks: tasks };
|
||||
});
|
||||
return newTask;
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to create scheduled task';
|
||||
@@ -602,8 +613,23 @@ function createConfigClientFromKernel(client: KernelClient): ConfigStoreClient {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
getQuickConfig: async () => ({ quickConfig: {} }),
|
||||
saveQuickConfig: async () => null,
|
||||
getQuickConfig: async () => {
|
||||
// Read from localStorage in kernel mode
|
||||
try {
|
||||
const stored = localStorage.getItem('zclaw-quick-config');
|
||||
if (stored) {
|
||||
return { quickConfig: JSON.parse(stored) };
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return { quickConfig: {} };
|
||||
},
|
||||
saveQuickConfig: async (config) => {
|
||||
// Persist to localStorage in kernel mode
|
||||
try {
|
||||
localStorage.setItem('zclaw-quick-config', JSON.stringify(config));
|
||||
} catch { /* ignore */ }
|
||||
return { quickConfig: config };
|
||||
},
|
||||
listSkills: async () => {
|
||||
try {
|
||||
const result = await client.listSkills();
|
||||
|
||||
@@ -522,13 +522,180 @@ export function createHandClientFromGateway(client: GatewayClient): HandClient {
|
||||
};
|
||||
}
|
||||
|
||||
// === Kernel Client Adapter ===
|
||||
|
||||
import type { KernelClient } from '../lib/kernel-client';
|
||||
|
||||
/**
|
||||
* Helper to create a HandClient adapter from a KernelClient.
|
||||
* Maps KernelClient methods (Tauri invoke) to the HandClient interface.
|
||||
*/
|
||||
function createHandClientFromKernel(client: KernelClient): HandClient {
|
||||
return {
|
||||
listHands: async () => {
|
||||
try {
|
||||
const result = await client.listHands();
|
||||
// KernelClient returns typed objects; cast to Record<string, unknown> for HandClient compatibility
|
||||
const hands: Array<Record<string, unknown>> = result.hands.map((h) => ({
|
||||
id: h.id || h.name,
|
||||
name: h.name,
|
||||
description: h.description,
|
||||
status: h.status,
|
||||
requirements_met: h.requirements_met,
|
||||
category: h.category,
|
||||
icon: h.icon,
|
||||
tool_count: h.tool_count,
|
||||
tools: h.tools,
|
||||
metric_count: h.metric_count,
|
||||
metrics: h.metrics,
|
||||
}));
|
||||
return { hands };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
getHand: async (name: string) => {
|
||||
try {
|
||||
const result = await client.getHand(name);
|
||||
return result as Record<string, unknown> || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
listHandRuns: async (name: string, opts) => {
|
||||
try {
|
||||
const result = await client.listHandRuns(name, opts);
|
||||
return result as unknown as { runs?: RawHandRun[] } | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
triggerHand: async (name: string, params) => {
|
||||
try {
|
||||
const result = await client.triggerHand(name, params);
|
||||
return { runId: result.runId, status: result.status };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
approveHand: async (name: string, runId: string, approved: boolean, reason?: string) => {
|
||||
return client.approveHand(name, runId, approved, reason);
|
||||
},
|
||||
cancelHand: async (name: string, runId: string) => {
|
||||
return client.cancelHand(name, runId);
|
||||
},
|
||||
listTriggers: async () => {
|
||||
try {
|
||||
const result = await client.listTriggers();
|
||||
if (!result?.triggers) return { triggers: [] };
|
||||
// Map KernelClient trigger shape to HandClient Trigger shape
|
||||
const triggers: Trigger[] = result.triggers.map((t) => ({
|
||||
id: t.id,
|
||||
type: t.triggerType,
|
||||
enabled: t.enabled,
|
||||
}));
|
||||
return { triggers };
|
||||
} catch {
|
||||
return { triggers: [] };
|
||||
}
|
||||
},
|
||||
getTrigger: async (id: string) => {
|
||||
try {
|
||||
const result = await client.getTrigger(id);
|
||||
if (!result) return null;
|
||||
return {
|
||||
id: result.id,
|
||||
type: result.triggerType,
|
||||
enabled: result.enabled,
|
||||
} as Trigger;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
createTrigger: async (trigger) => {
|
||||
try {
|
||||
const result = await client.createTrigger({
|
||||
id: `${trigger.type}_${Date.now()}`,
|
||||
name: trigger.name || trigger.type,
|
||||
handId: trigger.handName || '',
|
||||
triggerType: { type: trigger.type },
|
||||
enabled: trigger.enabled,
|
||||
description: trigger.config ? JSON.stringify(trigger.config) : undefined,
|
||||
});
|
||||
return result ? { id: result.id } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
updateTrigger: async (id: string, updates) => {
|
||||
const result = await client.updateTrigger(id, {
|
||||
name: updates.name,
|
||||
enabled: updates.enabled,
|
||||
handId: updates.handName,
|
||||
triggerType: updates.config ? { type: (updates.config as Record<string, unknown>).type as string } : undefined,
|
||||
});
|
||||
return { id: result.id };
|
||||
},
|
||||
deleteTrigger: async (id: string) => {
|
||||
await client.deleteTrigger(id);
|
||||
return { status: 'deleted' };
|
||||
},
|
||||
listApprovals: async () => {
|
||||
try {
|
||||
const result = await client.listApprovals();
|
||||
// Map KernelClient approval shape to HandClient RawApproval shape
|
||||
const approvals: RawApproval[] = (result?.approvals || []).map((a) => ({
|
||||
id: a.id,
|
||||
hand_id: a.handId,
|
||||
status: a.status,
|
||||
requestedAt: a.createdAt,
|
||||
}));
|
||||
return { approvals };
|
||||
} catch {
|
||||
return { approvals: [] };
|
||||
}
|
||||
},
|
||||
respondToApproval: async (approvalId: string, approved: boolean, reason?: string) => {
|
||||
await client.respondToApproval(approvalId, approved, reason);
|
||||
return { status: approved ? 'approved' : 'rejected' };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// === Client Injection ===
|
||||
|
||||
/**
|
||||
* Sets the client for the hand store.
|
||||
* Called by the coordinator during initialization.
|
||||
* Detects whether the client is a KernelClient (Tauri) or GatewayClient (browser).
|
||||
*/
|
||||
export function setHandStoreClient(client: unknown): void {
|
||||
const handClient = createHandClientFromGateway(client as GatewayClient);
|
||||
let handClient: HandClient;
|
||||
|
||||
// Check if it's a KernelClient (has listHands method that returns typed objects)
|
||||
if (client && typeof client === 'object' && 'listHands' in client) {
|
||||
handClient = createHandClientFromKernel(client as KernelClient);
|
||||
} else if (client && typeof client === 'object') {
|
||||
// It's a GatewayClient
|
||||
handClient = createHandClientFromGateway(client as GatewayClient);
|
||||
} else {
|
||||
// Fallback: return a stub client that gracefully handles all calls
|
||||
handClient = {
|
||||
listHands: async () => null,
|
||||
getHand: async () => null,
|
||||
listHandRuns: async () => null,
|
||||
triggerHand: async () => null,
|
||||
approveHand: async () => ({ status: 'error' }),
|
||||
cancelHand: async () => ({ status: 'error' }),
|
||||
listTriggers: async () => ({ triggers: [] }),
|
||||
getTrigger: async () => null,
|
||||
createTrigger: async () => null,
|
||||
updateTrigger: async () => ({ id: '' }),
|
||||
deleteTrigger: async () => ({ status: 'error' }),
|
||||
listApprovals: async () => ({ approvals: [] }),
|
||||
respondToApproval: async () => ({ status: 'error' }),
|
||||
};
|
||||
}
|
||||
|
||||
useHandStore.getState().setHandStoreClient(handClient);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { GatewayClient } from '../lib/gateway-client';
|
||||
import type { KernelClient } from '../lib/kernel-client';
|
||||
|
||||
// === Core Types (previously imported from gatewayStore) ===
|
||||
|
||||
@@ -327,11 +329,168 @@ function createWorkflowClientFromGateway(client: GatewayClient): WorkflowClient
|
||||
};
|
||||
}
|
||||
|
||||
// === Pipeline types (from Tauri backend) ===
|
||||
|
||||
interface PipelineInfo {
|
||||
id: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
category: string;
|
||||
industry: string;
|
||||
tags: string[];
|
||||
icon: string;
|
||||
version: string;
|
||||
author: string;
|
||||
inputs: Array<{
|
||||
name: string;
|
||||
inputType: string;
|
||||
required: boolean;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
default?: unknown;
|
||||
options: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
interface RunPipelineResponse {
|
||||
runId: string;
|
||||
pipelineId: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface PipelineRunResponse {
|
||||
runId: string;
|
||||
pipelineId: string;
|
||||
status: string;
|
||||
currentStep?: string;
|
||||
percentage: number;
|
||||
message: string;
|
||||
outputs?: unknown;
|
||||
error?: string;
|
||||
startedAt: string;
|
||||
endedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a WorkflowClient adapter from a KernelClient.
|
||||
* Uses direct Tauri invoke() calls to pipeline_commands since KernelClient
|
||||
* does not have workflow methods (workflows in Tauri mode are pipelines).
|
||||
*/
|
||||
function createWorkflowClientFromKernel(_client: KernelClient): WorkflowClient {
|
||||
return {
|
||||
listWorkflows: async () => {
|
||||
try {
|
||||
const pipelines = await invoke<PipelineInfo[]>('pipeline_list', {});
|
||||
if (!pipelines) return null;
|
||||
return {
|
||||
workflows: pipelines.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.displayName || p.id,
|
||||
steps: p.inputs.length,
|
||||
description: p.description,
|
||||
createdAt: undefined,
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
getWorkflow: async (id: string) => {
|
||||
try {
|
||||
const pipeline = await invoke<PipelineInfo>('pipeline_get', { pipelineId: id });
|
||||
return {
|
||||
id: pipeline.id,
|
||||
name: pipeline.displayName || pipeline.id,
|
||||
description: pipeline.description,
|
||||
steps: pipeline.inputs.map((input) => ({
|
||||
handName: input.inputType,
|
||||
name: input.label,
|
||||
params: input.default ? { default: input.default } : undefined,
|
||||
})),
|
||||
createdAt: undefined,
|
||||
} satisfies WorkflowDetail;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
createWorkflow: async () => {
|
||||
throw new Error('Workflow creation not supported in KernelClient mode. Pipelines are file-based YAML definitions.');
|
||||
},
|
||||
updateWorkflow: async () => {
|
||||
throw new Error('Workflow update not supported in KernelClient mode. Pipelines are file-based YAML definitions.');
|
||||
},
|
||||
deleteWorkflow: async () => {
|
||||
throw new Error('Workflow deletion not supported in KernelClient mode. Pipelines are file-based YAML definitions.');
|
||||
},
|
||||
executeWorkflow: async (id: string, input?: Record<string, unknown>) => {
|
||||
try {
|
||||
const result = await invoke<RunPipelineResponse>('pipeline_run', {
|
||||
request: { pipelineId: id, inputs: input || {} },
|
||||
});
|
||||
return { runId: result.runId, status: result.status };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
cancelWorkflow: async (_workflowId: string, runId: string) => {
|
||||
try {
|
||||
await invoke('pipeline_cancel', { runId });
|
||||
return { status: 'cancelled' };
|
||||
} catch {
|
||||
return { status: 'error' };
|
||||
}
|
||||
},
|
||||
listWorkflowRuns: async (workflowId: string) => {
|
||||
try {
|
||||
const runs = await invoke<PipelineRunResponse[]>('pipeline_runs', {});
|
||||
// Filter runs by pipeline ID and map to RawWorkflowRun shape
|
||||
const filteredRuns: RawWorkflowRun[] = runs
|
||||
.filter((r) => r.pipelineId === workflowId)
|
||||
.map((r) => ({
|
||||
run_id: r.runId,
|
||||
workflow_id: r.pipelineId,
|
||||
status: r.status,
|
||||
started_at: r.startedAt,
|
||||
completed_at: r.endedAt,
|
||||
current_step: r.currentStep ? Math.round(r.percentage) : undefined,
|
||||
error: r.error,
|
||||
result: r.outputs,
|
||||
}));
|
||||
return { runs: filteredRuns };
|
||||
} catch {
|
||||
return { runs: [] };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the client for the workflow store.
|
||||
* Called by the coordinator during initialization.
|
||||
* Detects whether the client is a KernelClient (Tauri) or GatewayClient (browser).
|
||||
*/
|
||||
export function setWorkflowStoreClient(client: unknown): void {
|
||||
const workflowClient = createWorkflowClientFromGateway(client as GatewayClient);
|
||||
let workflowClient: WorkflowClient;
|
||||
|
||||
// Check if it's a KernelClient (has listHands method, which KernelClient has but GatewayClient doesn't)
|
||||
if (client && typeof client === 'object' && 'listHands' in client) {
|
||||
workflowClient = createWorkflowClientFromKernel(client as KernelClient);
|
||||
} else if (client && typeof client === 'object') {
|
||||
// It's a GatewayClient
|
||||
workflowClient = createWorkflowClientFromGateway(client as GatewayClient);
|
||||
} else {
|
||||
// Fallback: return a stub client that gracefully handles all calls
|
||||
workflowClient = {
|
||||
listWorkflows: async () => null,
|
||||
getWorkflow: async () => null,
|
||||
createWorkflow: async () => null,
|
||||
updateWorkflow: async () => null,
|
||||
deleteWorkflow: async () => ({ status: 'error' }),
|
||||
executeWorkflow: async () => null,
|
||||
cancelWorkflow: async () => ({ status: 'error' }),
|
||||
listWorkflowRuns: async () => null,
|
||||
};
|
||||
}
|
||||
|
||||
useWorkflowStore.getState().setWorkflowStoreClient(workflowClient);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user