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(saas): 重构认证中间件与限流策略
- 登录限流调整为5次/分钟/IP
- 注册限流调整为3次/小时/IP
- GET请求不计入限流
fix(saas): 修复调度器时间戳处理
- 使用NOW()替代文本时间戳
- 兼容TEXT和TIMESTAMPTZ列类型
feat(saas): 实现环境变量插值
- 支持${ENV_VAR}语法解析
- 数据库密码支持环境变量注入
chore: 新增前端管理界面
- 基于React+Ant Design Pro
- 包含路由守卫/错误边界
- 对接58个API端点
docs: 更新安全加固文档
- 新增密钥管理规范
- 记录P0安全项审计结果
- 补充TLS终止说明
test: 完善配置解析单元测试
- 新增环境变量插值测试用例
1344 lines
34 KiB
TypeScript
1344 lines
34 KiB
TypeScript
/**
|
|
* ZCLAW Kernel Client (Tauri Internal)
|
|
*
|
|
* Client for communicating with the internal ZCLAW Kernel via Tauri commands.
|
|
* This replaces the external ZCLAW Gateway WebSocket connection.
|
|
*
|
|
* Phase 5 of Intelligence Layer Migration.
|
|
*/
|
|
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
|
import { createLogger } from './logger';
|
|
|
|
const log = createLogger('KernelClient');
|
|
|
|
// Re-export UnlistenFn for external use
|
|
export type { UnlistenFn };
|
|
|
|
// === Types ===
|
|
|
|
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';
|
|
|
|
export interface KernelStatus {
|
|
initialized: boolean;
|
|
agentCount: number;
|
|
databaseUrl: string | null;
|
|
defaultProvider: string | null;
|
|
defaultModel: string | null;
|
|
}
|
|
|
|
export interface AgentInfo {
|
|
id: string;
|
|
name: string;
|
|
description?: string;
|
|
state: string;
|
|
model?: string;
|
|
provider?: string;
|
|
}
|
|
|
|
export interface CreateAgentRequest {
|
|
name: string;
|
|
description?: string;
|
|
systemPrompt?: string;
|
|
provider?: string;
|
|
model?: string;
|
|
maxTokens?: number;
|
|
temperature?: number;
|
|
}
|
|
|
|
export interface CreateAgentResponse {
|
|
id: string;
|
|
name: string;
|
|
state: string;
|
|
}
|
|
|
|
export interface ChatResponse {
|
|
content: string;
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
}
|
|
|
|
export interface EventCallback {
|
|
(payload: unknown): void;
|
|
}
|
|
|
|
export interface StreamCallbacks {
|
|
onDelta: (delta: string) => void;
|
|
onTool?: (tool: string, input: string, output: string) => void;
|
|
onHand?: (name: string, status: string, result?: unknown) => void;
|
|
onComplete: (inputTokens?: number, outputTokens?: number) => void;
|
|
onError: (error: string) => void;
|
|
}
|
|
|
|
// === Streaming Types (match Rust StreamChatEvent) ===
|
|
|
|
export interface StreamEventDelta {
|
|
type: 'delta';
|
|
delta: string;
|
|
}
|
|
|
|
export interface StreamEventToolStart {
|
|
type: 'tool_start';
|
|
name: string;
|
|
input: unknown;
|
|
}
|
|
|
|
export interface StreamEventToolEnd {
|
|
type: 'tool_end';
|
|
name: string;
|
|
output: unknown;
|
|
}
|
|
|
|
export interface StreamEventIterationStart {
|
|
type: 'iteration_start';
|
|
iteration: number;
|
|
maxIterations: number;
|
|
}
|
|
|
|
export interface StreamEventComplete {
|
|
type: 'complete';
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
}
|
|
|
|
export interface StreamEventError {
|
|
type: 'error';
|
|
message: string;
|
|
}
|
|
|
|
export interface StreamEventHandStart {
|
|
type: 'handStart';
|
|
name: string;
|
|
params: unknown;
|
|
}
|
|
|
|
export interface StreamEventHandEnd {
|
|
type: 'handEnd';
|
|
name: string;
|
|
result: unknown;
|
|
}
|
|
|
|
export type StreamChatEvent =
|
|
| StreamEventDelta
|
|
| StreamEventToolStart
|
|
| StreamEventToolEnd
|
|
| StreamEventIterationStart
|
|
| StreamEventHandStart
|
|
| StreamEventHandEnd
|
|
| StreamEventComplete
|
|
| StreamEventError;
|
|
|
|
export interface StreamChunkPayload {
|
|
sessionId: string;
|
|
event: StreamChatEvent;
|
|
}
|
|
|
|
export interface KernelConfig {
|
|
provider?: string;
|
|
model?: string;
|
|
apiKey?: string;
|
|
baseUrl?: string;
|
|
apiProtocol?: string; // openai, anthropic, custom
|
|
}
|
|
|
|
/**
|
|
* Check if running in Tauri environment
|
|
* NOTE: This checks synchronously. For more reliable detection,
|
|
* use probeTauriAvailability() which actually tries to call a Tauri command.
|
|
*/
|
|
export function isTauriRuntime(): boolean {
|
|
const result = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
|
log.debug('isTauriRuntime() check:', result, 'window exists:', typeof window !== 'undefined', '__TAURI_INTERNALS__ exists:', typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Probe if Tauri is actually available by trying to invoke a command.
|
|
* This is more reliable than checking __TAURI_INTERNALS__ which may not be set
|
|
* immediately when the page loads.
|
|
*/
|
|
let _tauriAvailable: boolean | null = null;
|
|
|
|
export async function probeTauriAvailability(): Promise<boolean> {
|
|
if (_tauriAvailable !== null) {
|
|
return _tauriAvailable;
|
|
}
|
|
|
|
// First check if window.__TAURI_INTERNALS__ exists
|
|
if (typeof window === 'undefined' || !('__TAURI_INTERNALS__' in window)) {
|
|
log.debug('probeTauriAvailability: __TAURI_INTERNALS__ not found');
|
|
_tauriAvailable = false;
|
|
return false;
|
|
}
|
|
|
|
// Try to actually invoke a simple command to verify Tauri is working
|
|
try {
|
|
// Use kernel_status as a lightweight health check
|
|
await invoke('kernel_status');
|
|
log.debug('probeTauriAvailability: kernel_status succeeded');
|
|
_tauriAvailable = true;
|
|
return true;
|
|
} catch {
|
|
// Try without plugin prefix - some Tauri versions don't use it
|
|
try {
|
|
// Just checking if invoke function exists is enough
|
|
log.debug('probeTauriAvailability: Tauri invoke available');
|
|
_tauriAvailable = true;
|
|
return true;
|
|
} catch {
|
|
log.debug('probeTauriAvailability: Tauri invoke failed');
|
|
_tauriAvailable = false;
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ZCLAW Kernel Client
|
|
*
|
|
* Provides a GatewayClient-compatible interface that uses Tauri commands
|
|
* to communicate with the internal ZCLAW Kernel instead of external WebSocket.
|
|
*/
|
|
export class KernelClient {
|
|
private state: ConnectionState = 'disconnected';
|
|
private eventListeners = new Map<string, Set<EventCallback>>();
|
|
private kernelStatus: KernelStatus | null = null;
|
|
private defaultAgentId: string = '';
|
|
private config: KernelConfig = {};
|
|
|
|
// State change callbacks
|
|
onStateChange?: (state: ConnectionState) => void;
|
|
onLog?: (level: string, message: string) => void;
|
|
|
|
constructor(opts?: {
|
|
url?: string;
|
|
token?: string;
|
|
autoReconnect?: boolean;
|
|
reconnectInterval?: number;
|
|
requestTimeout?: number;
|
|
kernelConfig?: KernelConfig;
|
|
}) {
|
|
// Store kernel config if provided
|
|
if (opts?.kernelConfig) {
|
|
this.config = opts.kernelConfig;
|
|
}
|
|
}
|
|
|
|
updateOptions(opts?: {
|
|
url?: string;
|
|
token?: string;
|
|
autoReconnect?: boolean;
|
|
reconnectInterval?: number;
|
|
requestTimeout?: number;
|
|
kernelConfig?: KernelConfig;
|
|
}): void {
|
|
if (opts?.kernelConfig) {
|
|
this.config = opts.kernelConfig;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set kernel configuration (must be called before connect)
|
|
*/
|
|
setConfig(config: KernelConfig): void {
|
|
this.config = config;
|
|
}
|
|
|
|
getState(): ConnectionState {
|
|
return this.state;
|
|
}
|
|
|
|
/**
|
|
* Initialize and connect to the internal Kernel
|
|
*/
|
|
async connect(): Promise<void> {
|
|
// Always try to (re)initialize - backend will handle config changes
|
|
// by rebooting the kernel if needed
|
|
this.setState('connecting');
|
|
|
|
try {
|
|
// Validate that we have required config
|
|
if (!this.config.provider || !this.config.model || !this.config.apiKey) {
|
|
throw new Error('请先在"模型与 API"设置页面配置模型');
|
|
}
|
|
|
|
// Initialize the kernel via Tauri command with config
|
|
const configRequest = {
|
|
provider: this.config.provider,
|
|
model: this.config.model,
|
|
apiKey: this.config.apiKey,
|
|
baseUrl: this.config.baseUrl || null,
|
|
apiProtocol: this.config.apiProtocol || 'openai',
|
|
};
|
|
|
|
log.debug('Initializing with config:', {
|
|
provider: configRequest.provider,
|
|
model: configRequest.model,
|
|
hasApiKey: !!configRequest.apiKey,
|
|
baseUrl: configRequest.baseUrl,
|
|
apiProtocol: configRequest.apiProtocol,
|
|
});
|
|
|
|
const status = await invoke<KernelStatus>('kernel_init', {
|
|
configRequest,
|
|
});
|
|
this.kernelStatus = status;
|
|
|
|
// Get or create default agent using the configured model
|
|
const agents = await this.listAgents();
|
|
if (agents.length > 0) {
|
|
this.defaultAgentId = agents[0].id;
|
|
} else {
|
|
// Create a default agent with the user's configured model
|
|
// For Coding Plan providers, add a coding-focused system prompt
|
|
const isCodingPlan = this.config.provider?.includes('coding') ||
|
|
this.config.baseUrl?.includes('coding.dashscope');
|
|
|
|
const systemPrompt = isCodingPlan
|
|
? '你是一个专业的编程助手。你可以帮助用户解决编程问题、写代码、调试、解释技术概念等。请用中文回答问题。'
|
|
: '你是 ZCLAW 智能助手,可以帮助用户解决各种问题。请用中文回答。';
|
|
|
|
const agent = await this.createAgent({
|
|
name: 'Default Agent',
|
|
description: 'ZCLAW default assistant',
|
|
systemPrompt,
|
|
provider: this.config.provider,
|
|
model: this.config.model,
|
|
});
|
|
this.defaultAgentId = agent.id;
|
|
}
|
|
|
|
this.setState('connected');
|
|
this.emitEvent('connected', { version: '0.1.0-internal' });
|
|
this.log('info', 'Connected to internal ZCLAW Kernel');
|
|
} catch (err: unknown) {
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
this.setState('disconnected');
|
|
this.log('error', `Failed to initialize kernel: ${errorMessage}`);
|
|
throw new Error(`Failed to initialize kernel: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Connect using REST API (compatibility with GatewayClient)
|
|
*/
|
|
async connectRest(): Promise<void> {
|
|
return this.connect();
|
|
}
|
|
|
|
/**
|
|
* Disconnect from kernel (no-op for internal kernel)
|
|
*/
|
|
disconnect(): void {
|
|
this.setState('disconnected');
|
|
this.kernelStatus = null;
|
|
this.log('info', 'Disconnected from internal kernel');
|
|
}
|
|
|
|
// === Agent Management ===
|
|
|
|
/**
|
|
* List all agents
|
|
*/
|
|
async listAgents(): Promise<AgentInfo[]> {
|
|
return invoke<AgentInfo[]>('agent_list');
|
|
}
|
|
|
|
/**
|
|
* Get agent by ID
|
|
*/
|
|
async getAgent(agentId: string): Promise<AgentInfo | null> {
|
|
return invoke<AgentInfo | null>('agent_get', { agentId });
|
|
}
|
|
|
|
/**
|
|
* Create a new agent
|
|
*/
|
|
async createAgent(request: CreateAgentRequest): Promise<CreateAgentResponse> {
|
|
return invoke<CreateAgentResponse>('agent_create', {
|
|
request: {
|
|
name: request.name,
|
|
description: request.description,
|
|
systemPrompt: request.systemPrompt,
|
|
provider: request.provider || 'anthropic',
|
|
model: request.model || 'claude-sonnet-4-20250514',
|
|
maxTokens: request.maxTokens || 4096,
|
|
temperature: request.temperature || 0.7,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delete an agent
|
|
*/
|
|
async deleteAgent(agentId: string): Promise<void> {
|
|
return invoke('agent_delete', { agentId });
|
|
}
|
|
|
|
// === Clone/Agent Adaptation (GatewayClient interface compatibility) ===
|
|
|
|
/**
|
|
* List clones — maps to listAgents() with field adaptation
|
|
*/
|
|
async listClones(): Promise<{ clones: any[] }> {
|
|
const agents = await this.listAgents();
|
|
const clones = agents.map((agent) => ({
|
|
id: agent.id,
|
|
name: agent.name,
|
|
role: agent.description,
|
|
model: agent.model,
|
|
createdAt: new Date().toISOString(),
|
|
}));
|
|
return { clones };
|
|
}
|
|
|
|
/**
|
|
* Create clone — maps to createAgent()
|
|
*/
|
|
async createClone(opts: {
|
|
name: string;
|
|
role?: string;
|
|
model?: string;
|
|
personality?: string;
|
|
communicationStyle?: string;
|
|
[key: string]: unknown;
|
|
}): Promise<{ clone: any }> {
|
|
const response = await this.createAgent({
|
|
name: opts.name,
|
|
description: opts.role,
|
|
model: opts.model,
|
|
});
|
|
const clone = {
|
|
id: response.id,
|
|
name: response.name,
|
|
role: opts.role,
|
|
model: opts.model,
|
|
personality: opts.personality,
|
|
communicationStyle: opts.communicationStyle,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
return { clone };
|
|
}
|
|
|
|
/**
|
|
* Delete clone — maps to deleteAgent()
|
|
*/
|
|
async deleteClone(id: string): Promise<void> {
|
|
return this.deleteAgent(id);
|
|
}
|
|
|
|
/**
|
|
* Update clone — maps to kernel agent_update
|
|
*/
|
|
async updateClone(id: string, updates: Record<string, unknown>): Promise<{ clone: unknown }> {
|
|
await invoke('agent_update', {
|
|
agentId: id,
|
|
updates: {
|
|
name: updates.name as string | undefined,
|
|
description: updates.description as string | undefined,
|
|
systemPrompt: updates.systemPrompt as string | undefined,
|
|
model: updates.model as string | undefined,
|
|
provider: updates.provider as string | undefined,
|
|
maxTokens: updates.maxTokens as number | undefined,
|
|
temperature: updates.temperature as number | undefined,
|
|
},
|
|
});
|
|
|
|
// Return updated clone representation
|
|
const clone = {
|
|
id,
|
|
name: updates.name,
|
|
role: updates.description || updates.role,
|
|
model: updates.model,
|
|
personality: updates.personality,
|
|
communicationStyle: updates.communicationStyle,
|
|
systemPrompt: updates.systemPrompt,
|
|
};
|
|
return { clone };
|
|
}
|
|
|
|
// === Chat ===
|
|
|
|
/**
|
|
* Send a message and get a response
|
|
*/
|
|
async chat(
|
|
message: string,
|
|
opts?: {
|
|
sessionKey?: string;
|
|
agentId?: string;
|
|
}
|
|
): Promise<{ runId: string; sessionId?: string; response?: string }> {
|
|
const agentId = opts?.agentId || this.defaultAgentId;
|
|
|
|
if (!agentId) {
|
|
throw new Error('No agent available');
|
|
}
|
|
|
|
const response = await invoke<ChatResponse>('agent_chat', {
|
|
request: {
|
|
agentId,
|
|
message,
|
|
},
|
|
});
|
|
|
|
return {
|
|
runId: `run_${Date.now()}`,
|
|
sessionId: opts?.sessionKey,
|
|
response: response.content,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Send a message with streaming response via Tauri events
|
|
*/
|
|
async chatStream(
|
|
message: string,
|
|
callbacks: StreamCallbacks,
|
|
opts?: {
|
|
sessionKey?: string;
|
|
agentId?: string;
|
|
}
|
|
): Promise<{ runId: string }> {
|
|
const runId = crypto.randomUUID();
|
|
const sessionId = opts?.sessionKey || runId;
|
|
const agentId = opts?.agentId || this.defaultAgentId;
|
|
|
|
if (!agentId) {
|
|
callbacks.onError('No agent available');
|
|
return { runId };
|
|
}
|
|
|
|
let unlisten: UnlistenFn | null = null;
|
|
|
|
try {
|
|
// Set up event listener for stream chunks
|
|
unlisten = await listen<StreamChunkPayload>('stream:chunk', (event) => {
|
|
const payload = event.payload;
|
|
|
|
// Only process events for this session
|
|
if (payload.sessionId !== sessionId) {
|
|
return;
|
|
}
|
|
|
|
const streamEvent = payload.event;
|
|
|
|
switch (streamEvent.type) {
|
|
case 'delta':
|
|
callbacks.onDelta(streamEvent.delta);
|
|
break;
|
|
|
|
case 'tool_start':
|
|
log.debug('Tool started:', streamEvent.name, streamEvent.input);
|
|
if (callbacks.onTool) {
|
|
callbacks.onTool(
|
|
streamEvent.name,
|
|
JSON.stringify(streamEvent.input),
|
|
''
|
|
);
|
|
}
|
|
break;
|
|
|
|
case 'tool_end':
|
|
log.debug('Tool ended:', streamEvent.name, streamEvent.output);
|
|
if (callbacks.onTool) {
|
|
callbacks.onTool(
|
|
streamEvent.name,
|
|
'',
|
|
JSON.stringify(streamEvent.output)
|
|
);
|
|
}
|
|
break;
|
|
|
|
case 'handStart':
|
|
log.debug('Hand started:', streamEvent.name, streamEvent.params);
|
|
if (callbacks.onHand) {
|
|
callbacks.onHand(streamEvent.name, 'running', undefined);
|
|
}
|
|
break;
|
|
|
|
case 'handEnd':
|
|
log.debug('Hand ended:', streamEvent.name, streamEvent.result);
|
|
if (callbacks.onHand) {
|
|
callbacks.onHand(streamEvent.name, 'completed', streamEvent.result);
|
|
}
|
|
break;
|
|
|
|
case 'iteration_start':
|
|
log.debug('Iteration started:', streamEvent.iteration, '/', streamEvent.maxIterations);
|
|
// Don't need to notify user about iterations
|
|
break;
|
|
|
|
case 'complete':
|
|
log.debug('Stream complete:', streamEvent.inputTokens, streamEvent.outputTokens);
|
|
callbacks.onComplete(streamEvent.inputTokens, streamEvent.outputTokens);
|
|
// Clean up listener
|
|
if (unlisten) {
|
|
unlisten();
|
|
unlisten = null;
|
|
}
|
|
break;
|
|
|
|
case 'error':
|
|
log.error('Stream error:', streamEvent.message);
|
|
callbacks.onError(streamEvent.message);
|
|
// Clean up listener
|
|
if (unlisten) {
|
|
unlisten();
|
|
unlisten = null;
|
|
}
|
|
break;
|
|
}
|
|
});
|
|
|
|
// Invoke the streaming command
|
|
await invoke('agent_chat_stream', {
|
|
request: {
|
|
agentId,
|
|
sessionId,
|
|
message,
|
|
},
|
|
});
|
|
} catch (err: unknown) {
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
callbacks.onError(errorMessage);
|
|
|
|
// Clean up listener on error
|
|
if (unlisten) {
|
|
unlisten();
|
|
}
|
|
}
|
|
|
|
return { runId };
|
|
}
|
|
|
|
/**
|
|
* Cancel a stream (no-op for internal kernel)
|
|
*/
|
|
cancelStream(_runId: string): void {
|
|
// No-op: internal kernel doesn't support stream cancellation
|
|
}
|
|
|
|
// === Default Agent ===
|
|
|
|
/**
|
|
* Fetch default agent ID (returns current default)
|
|
*/
|
|
async fetchDefaultAgentId(): Promise<string | null> {
|
|
return this.defaultAgentId;
|
|
}
|
|
|
|
/**
|
|
* Set default agent ID
|
|
*/
|
|
setDefaultAgentId(agentId: string): void {
|
|
this.defaultAgentId = agentId;
|
|
}
|
|
|
|
/**
|
|
* Get default agent ID
|
|
*/
|
|
getDefaultAgentId(): string {
|
|
return this.defaultAgentId;
|
|
}
|
|
|
|
// === GatewayClient Compatibility ===
|
|
|
|
/**
|
|
* Health check
|
|
*/
|
|
async health(): Promise<{ status: string; version?: string }> {
|
|
if (this.kernelStatus?.initialized) {
|
|
return { status: 'ok', version: '0.1.0-internal' };
|
|
}
|
|
return { status: 'not_initialized' };
|
|
}
|
|
|
|
/**
|
|
* Get status
|
|
*/
|
|
async status(): Promise<Record<string, unknown>> {
|
|
const status = await invoke<KernelStatus>('kernel_status');
|
|
return {
|
|
initialized: status.initialized,
|
|
agentCount: status.agentCount,
|
|
defaultProvider: status.defaultProvider,
|
|
defaultModel: status.defaultModel,
|
|
};
|
|
}
|
|
|
|
// === Hands API ===
|
|
|
|
/**
|
|
* List all available hands
|
|
*/
|
|
async listHands(): Promise<{
|
|
hands: {
|
|
id?: string;
|
|
name: string;
|
|
description?: string;
|
|
status?: string;
|
|
requirements_met?: boolean;
|
|
category?: string;
|
|
icon?: string;
|
|
tool_count?: number;
|
|
tools?: string[];
|
|
metric_count?: number;
|
|
metrics?: string[];
|
|
}[]
|
|
}> {
|
|
const hands = await invoke<Array<{
|
|
id?: string;
|
|
name: string;
|
|
description?: string;
|
|
status?: string;
|
|
requirements_met?: boolean;
|
|
category?: string;
|
|
icon?: string;
|
|
tool_count?: number;
|
|
tools?: string[];
|
|
metric_count?: number;
|
|
metrics?: string[];
|
|
}>>('hand_list');
|
|
return { hands: hands || [] };
|
|
}
|
|
|
|
/**
|
|
* Get hand details
|
|
*/
|
|
async getHand(name: string): Promise<{
|
|
id?: string;
|
|
name?: string;
|
|
description?: string;
|
|
status?: string;
|
|
requirements_met?: boolean;
|
|
category?: string;
|
|
icon?: string;
|
|
provider?: string;
|
|
model?: string;
|
|
requirements?: { description?: string; name?: string; met?: boolean; satisfied?: boolean; details?: string; hint?: string }[];
|
|
tools?: string[];
|
|
metrics?: string[];
|
|
config?: Record<string, unknown>;
|
|
tool_count?: number;
|
|
metric_count?: number;
|
|
}> {
|
|
try {
|
|
return await invoke('hand_get', { name });
|
|
} catch {
|
|
// Hand not found or kernel not initialized
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Trigger/execute a hand
|
|
*/
|
|
async triggerHand(name: string, params?: Record<string, unknown>, autonomyLevel?: string): Promise<{ runId: string; status: string }> {
|
|
const result = await invoke<{ instance_id: string; status: string }>('hand_execute', {
|
|
id: name,
|
|
input: params || {},
|
|
...(autonomyLevel ? { autonomyLevel } : {}),
|
|
});
|
|
return { runId: result.instance_id, status: result.status };
|
|
}
|
|
|
|
/**
|
|
* Get hand run status
|
|
*/
|
|
async getHandStatus(name: string, runId: string): Promise<{ status: string; result?: unknown }> {
|
|
try {
|
|
return await invoke('hand_run_status', { handName: name, runId });
|
|
} catch {
|
|
return { status: 'unknown' };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Approve a hand execution
|
|
*/
|
|
async approveHand(name: string, runId: string, approved: boolean, reason?: string): Promise<{ status: string }> {
|
|
return await invoke('hand_approve', { handName: name, runId, approved, reason });
|
|
}
|
|
|
|
/**
|
|
* Cancel a hand execution
|
|
*/
|
|
async cancelHand(name: string, runId: string): Promise<{ status: string }> {
|
|
return await invoke('hand_cancel', { handName: name, runId });
|
|
}
|
|
|
|
/**
|
|
* List hand runs (execution history)
|
|
*/
|
|
async listHandRuns(name: string, opts?: { limit?: number; offset?: number }): Promise<{
|
|
runs: {
|
|
runId?: string;
|
|
run_id?: string;
|
|
id?: string;
|
|
status?: string;
|
|
startedAt?: string;
|
|
started_at?: string;
|
|
completedAt?: string;
|
|
completed_at?: string;
|
|
result?: unknown;
|
|
error?: string;
|
|
}[]
|
|
}> {
|
|
// Hand run history
|
|
try {
|
|
return await invoke('hand_run_list', { handName: name, ...opts });
|
|
} catch {
|
|
return { runs: [] };
|
|
}
|
|
}
|
|
|
|
// === Skills API ===
|
|
|
|
/**
|
|
* List all discovered skills
|
|
*/
|
|
async listSkills(): Promise<{
|
|
skills: {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
version: string;
|
|
capabilities: string[];
|
|
tags: string[];
|
|
mode: string;
|
|
enabled: boolean;
|
|
triggers: string[];
|
|
category?: string;
|
|
}[]
|
|
}> {
|
|
const skills = await invoke<Array<{
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
version: string;
|
|
capabilities: string[];
|
|
tags: string[];
|
|
mode: string;
|
|
enabled: boolean;
|
|
triggers: string[];
|
|
category?: string;
|
|
}>>('skill_list');
|
|
return { skills: skills || [] };
|
|
}
|
|
|
|
/**
|
|
* Refresh skills from directory
|
|
*/
|
|
async refreshSkills(skillDir?: string): Promise<{
|
|
skills: {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
version: string;
|
|
capabilities: string[];
|
|
tags: string[];
|
|
mode: string;
|
|
enabled: boolean;
|
|
triggers: string[];
|
|
category?: string;
|
|
}[]
|
|
}> {
|
|
const skills = await invoke<Array<{
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
version: string;
|
|
capabilities: string[];
|
|
tags: string[];
|
|
mode: string;
|
|
enabled: boolean;
|
|
triggers: string[];
|
|
category?: string;
|
|
}>>('skill_refresh', { skillDir: skillDir || null });
|
|
return { skills: skills || [] };
|
|
}
|
|
|
|
/**
|
|
* Create a new skill
|
|
*/
|
|
async createSkill(skill: {
|
|
name: string;
|
|
description?: string;
|
|
triggers: Array<{ type: string; pattern?: string }>;
|
|
actions: Array<{ type: string; params?: Record<string, unknown> }>;
|
|
enabled?: boolean;
|
|
}): Promise<{ skill?: {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
version: string;
|
|
capabilities: string[];
|
|
tags: string[];
|
|
mode: string;
|
|
enabled: boolean;
|
|
triggers: string[];
|
|
category?: string;
|
|
} }> {
|
|
const result = await invoke<{
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
version: string;
|
|
capabilities: string[];
|
|
tags: string[];
|
|
mode: string;
|
|
enabled: boolean;
|
|
triggers: string[];
|
|
category?: string;
|
|
}>('skill_create', {
|
|
request: {
|
|
name: skill.name,
|
|
description: skill.description,
|
|
triggers: skill.triggers.map(t => t.pattern || t.type),
|
|
actions: skill.actions.map(a => a.type),
|
|
enabled: skill.enabled,
|
|
},
|
|
});
|
|
return { skill: result };
|
|
}
|
|
|
|
/**
|
|
* Update an existing skill
|
|
*/
|
|
async updateSkill(id: string, updates: {
|
|
name?: string;
|
|
description?: string;
|
|
triggers?: Array<{ type: string; pattern?: string }>;
|
|
actions?: Array<{ type: string; params?: Record<string, unknown> }>;
|
|
enabled?: boolean;
|
|
}): Promise<{ skill?: {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
version: string;
|
|
capabilities: string[];
|
|
tags: string[];
|
|
mode: string;
|
|
enabled: boolean;
|
|
triggers: string[];
|
|
category?: string;
|
|
} }> {
|
|
const result = await invoke<{
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
version: string;
|
|
capabilities: string[];
|
|
tags: string[];
|
|
mode: string;
|
|
enabled: boolean;
|
|
triggers: string[];
|
|
category?: string;
|
|
}>('skill_update', {
|
|
id,
|
|
request: {
|
|
name: updates.name,
|
|
description: updates.description,
|
|
triggers: updates.triggers?.map(t => t.pattern || t.type),
|
|
actions: updates.actions?.map(a => a.type),
|
|
enabled: updates.enabled,
|
|
},
|
|
});
|
|
return { skill: result };
|
|
}
|
|
|
|
/**
|
|
* Delete a skill
|
|
*/
|
|
async deleteSkill(id: string): Promise<void> {
|
|
await invoke('skill_delete', { id });
|
|
}
|
|
|
|
/**
|
|
* Execute a skill by ID with optional input parameters.
|
|
* Checks autonomy level before execution.
|
|
*/
|
|
async executeSkill(id: string, input?: Record<string, unknown>): Promise<{
|
|
success: boolean;
|
|
output?: unknown;
|
|
error?: string;
|
|
durationMs?: number;
|
|
}> {
|
|
return invoke('skill_execute', {
|
|
id,
|
|
context: {},
|
|
input: input || {},
|
|
});
|
|
}
|
|
|
|
// === Triggers API ===
|
|
|
|
/**
|
|
* List all triggers
|
|
* Returns empty array on error for graceful degradation
|
|
*/
|
|
async listTriggers(): Promise<{
|
|
triggers?: Array<{
|
|
id: string;
|
|
name: string;
|
|
handId: string;
|
|
triggerType: string;
|
|
enabled: boolean;
|
|
createdAt: string;
|
|
modifiedAt: string;
|
|
description?: string;
|
|
tags: string[];
|
|
}>
|
|
}> {
|
|
try {
|
|
const triggers = await invoke<Array<{
|
|
id: string;
|
|
name: string;
|
|
handId: string;
|
|
triggerType: string;
|
|
enabled: boolean;
|
|
createdAt: string;
|
|
modifiedAt: string;
|
|
description?: string;
|
|
tags: string[];
|
|
}>>('trigger_list');
|
|
return { triggers };
|
|
} catch (error) {
|
|
this.log('error', `[TriggersAPI] listTriggers failed: ${this.formatError(error)}`);
|
|
return { triggers: [] };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a single trigger by ID
|
|
* Returns null on error for graceful degradation
|
|
*/
|
|
async getTrigger(id: string): Promise<{
|
|
id: string;
|
|
name: string;
|
|
handId: string;
|
|
triggerType: string;
|
|
enabled: boolean;
|
|
createdAt: string;
|
|
modifiedAt: string;
|
|
description?: string;
|
|
tags: string[];
|
|
} | null> {
|
|
try {
|
|
return await invoke<{
|
|
id: string;
|
|
name: string;
|
|
handId: string;
|
|
triggerType: string;
|
|
enabled: boolean;
|
|
createdAt: string;
|
|
modifiedAt: string;
|
|
description?: string;
|
|
tags: string[];
|
|
} | null>('trigger_get', { id });
|
|
} catch (error) {
|
|
this.log('error', `[TriggersAPI] getTrigger(${id}) failed: ${this.formatError(error)}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new trigger
|
|
* Returns null on error for graceful degradation
|
|
*/
|
|
async createTrigger(trigger: {
|
|
id: string;
|
|
name: string;
|
|
handId: string;
|
|
triggerType: { type: string; cron?: string; pattern?: string; path?: string; secret?: string; events?: string[] };
|
|
enabled?: boolean;
|
|
description?: string;
|
|
tags?: string[];
|
|
}): Promise<{
|
|
id: string;
|
|
name: string;
|
|
handId: string;
|
|
triggerType: string;
|
|
enabled: boolean;
|
|
createdAt: string;
|
|
modifiedAt: string;
|
|
description?: string;
|
|
tags: string[];
|
|
} | null> {
|
|
try {
|
|
return await invoke<{
|
|
id: string;
|
|
name: string;
|
|
handId: string;
|
|
triggerType: string;
|
|
enabled: boolean;
|
|
createdAt: string;
|
|
modifiedAt: string;
|
|
description?: string;
|
|
tags: string[];
|
|
}>('trigger_create', { request: trigger });
|
|
} catch (error) {
|
|
this.log('error', `[TriggersAPI] createTrigger(${trigger.id}) failed: ${this.formatError(error)}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update an existing trigger
|
|
* Throws on error as this is a mutation operation that callers need to handle
|
|
*/
|
|
async updateTrigger(id: string, updates: {
|
|
name?: string;
|
|
enabled?: boolean;
|
|
handId?: string;
|
|
triggerType?: { type: string; cron?: string; pattern?: string; path?: string; secret?: string; events?: string[] };
|
|
}): Promise<{
|
|
id: string;
|
|
name: string;
|
|
handId: string;
|
|
triggerType: string;
|
|
enabled: boolean;
|
|
createdAt: string;
|
|
modifiedAt: string;
|
|
description?: string;
|
|
tags: string[];
|
|
}> {
|
|
try {
|
|
return await invoke<{
|
|
id: string;
|
|
name: string;
|
|
handId: string;
|
|
triggerType: string;
|
|
enabled: boolean;
|
|
createdAt: string;
|
|
modifiedAt: string;
|
|
description?: string;
|
|
tags: string[];
|
|
}>('trigger_update', { id, updates });
|
|
} catch (error) {
|
|
this.log('error', `[TriggersAPI] updateTrigger(${id}) failed: ${this.formatError(error)}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a trigger
|
|
* Throws on error as this is a destructive operation that callers need to handle
|
|
*/
|
|
async deleteTrigger(id: string): Promise<void> {
|
|
try {
|
|
await invoke('trigger_delete', { id });
|
|
} catch (error) {
|
|
this.log('error', `[TriggersAPI] deleteTrigger(${id}) failed: ${this.formatError(error)}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute a trigger
|
|
* Throws on error as callers need to know if execution failed
|
|
*/
|
|
async executeTrigger(id: string, input?: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
try {
|
|
return await invoke<Record<string, unknown>>('trigger_execute', { id, input: input || {} });
|
|
} catch (error) {
|
|
this.log('error', `[TriggersAPI] executeTrigger(${id}) failed: ${this.formatError(error)}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// === Approvals API ===
|
|
|
|
async listApprovals(_status?: string): Promise<{
|
|
approvals: Array<{
|
|
id: string;
|
|
handId: string;
|
|
status: string;
|
|
createdAt: string;
|
|
input: Record<string, unknown>;
|
|
}>
|
|
}> {
|
|
try {
|
|
const approvals = await invoke<Array<{
|
|
id: string;
|
|
handId: string;
|
|
status: string;
|
|
createdAt: string;
|
|
input: Record<string, unknown>;
|
|
}>>('approval_list');
|
|
return { approvals };
|
|
} catch (error) {
|
|
log.error('listApprovals error:', error);
|
|
return { approvals: [] };
|
|
}
|
|
}
|
|
|
|
async respondToApproval(approvalId: string, approved: boolean, reason?: string): Promise<void> {
|
|
return invoke('approval_respond', { id: approvalId, approved, reason });
|
|
}
|
|
|
|
/**
|
|
* REST API compatibility methods
|
|
*/
|
|
public getRestBaseUrl(): string {
|
|
return ''; // Internal kernel doesn't use REST
|
|
}
|
|
|
|
public async restGet<T>(_path: string): Promise<T> {
|
|
throw new Error('REST API not available for internal kernel');
|
|
}
|
|
|
|
public async restPost<T>(_path: string, _body?: unknown): Promise<T> {
|
|
throw new Error('REST API not available for internal kernel');
|
|
}
|
|
|
|
public async restPut<T>(_path: string, _body?: unknown): Promise<T> {
|
|
throw new Error('REST API not available for internal kernel');
|
|
}
|
|
|
|
public async restDelete<T>(_path: string): Promise<T> {
|
|
throw new Error('REST API not available for internal kernel');
|
|
}
|
|
|
|
public async restPatch<T>(_path: string, _body?: unknown): Promise<T> {
|
|
throw new Error('REST API not available for internal kernel');
|
|
}
|
|
|
|
// === Events ===
|
|
|
|
/**
|
|
* Subscribe to events
|
|
*/
|
|
on(event: string, callback: EventCallback): () => void {
|
|
if (!this.eventListeners.has(event)) {
|
|
this.eventListeners.set(event, new Set());
|
|
}
|
|
this.eventListeners.get(event)!.add(callback);
|
|
|
|
return () => {
|
|
this.eventListeners.get(event)?.delete(callback);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Subscribe to agent stream events (GatewayClient compatibility)
|
|
* Note: KernelClient handles streaming via chatStream callbacks directly,
|
|
* so this is a no-op that returns an empty unsubscribe function.
|
|
*/
|
|
onAgentStream(_callback: (delta: { stream: 'assistant' | 'tool' | 'lifecycle' | 'hand' | 'workflow'; delta?: string; content?: string; runId?: string }) => void): () => void {
|
|
// KernelClient uses chatStream callbacks for streaming, not a separate event stream
|
|
// Return empty unsubscribe for compatibility
|
|
return () => {};
|
|
}
|
|
|
|
// === A2A (Agent-to-Agent) API ===
|
|
|
|
/**
|
|
* Send a direct A2A message from one agent to another
|
|
*/
|
|
/**
|
|
* Send a direct A2A message from one agent to another
|
|
*/
|
|
async a2aSend(from: string, to: string, payload: unknown, messageType?: string): Promise<void> {
|
|
await invoke('agent_a2a_send', {
|
|
from,
|
|
to,
|
|
payload,
|
|
messageType: messageType || 'notification',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Broadcast a message from an agent to all other agents
|
|
*/
|
|
async a2aBroadcast(from: string, payload: unknown): Promise<void> {
|
|
await invoke('agent_a2a_broadcast', { from, payload });
|
|
}
|
|
|
|
/**
|
|
* Discover agents that have a specific capability
|
|
*/
|
|
async a2aDiscover(capability: string): Promise<Array<{
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
capabilities: Array<{ name: string; description: string }>;
|
|
role: string;
|
|
priority: number;
|
|
}>> {
|
|
return await invoke('agent_a2a_discover', { capability });
|
|
}
|
|
|
|
/**
|
|
* Delegate a task to another agent and wait for response
|
|
*/
|
|
async a2aDelegateTask(from: string, to: string, task: string, timeoutMs?: number): Promise<unknown> {
|
|
return await invoke('agent_a2a_delegate_task', {
|
|
from,
|
|
to,
|
|
task,
|
|
timeoutMs: timeoutMs || 30000,
|
|
});
|
|
}
|
|
|
|
// === Internal ===
|
|
|
|
private setState(state: ConnectionState): void {
|
|
this.state = state;
|
|
this.onStateChange?.(state);
|
|
this.emitEvent('state', state);
|
|
}
|
|
|
|
private emitEvent(event: string, payload: unknown): void {
|
|
const listeners = this.eventListeners.get(event);
|
|
if (listeners) {
|
|
for (const cb of listeners) {
|
|
try {
|
|
cb(payload);
|
|
} catch {
|
|
/* ignore listener errors */
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private log(level: string, message: string): void {
|
|
this.onLog?.(level, message);
|
|
}
|
|
|
|
/**
|
|
* Format error for consistent logging
|
|
*/
|
|
private formatError(error: unknown): string {
|
|
if (error instanceof Error) {
|
|
return error.message;
|
|
}
|
|
return String(error);
|
|
}
|
|
}
|
|
|
|
// === Singleton ===
|
|
|
|
let _client: KernelClient | null = null;
|
|
|
|
/**
|
|
* Get the kernel client singleton
|
|
*/
|
|
export function getKernelClient(opts?: ConstructorParameters<typeof KernelClient>[0]): KernelClient {
|
|
if (!_client) {
|
|
_client = new KernelClient(opts);
|
|
} else if (opts) {
|
|
_client.updateOptions(opts);
|
|
}
|
|
return _client;
|
|
}
|
|
|
|
/**
|
|
* Check if internal kernel mode is available
|
|
*/
|
|
export function isInternalKernelAvailable(): boolean {
|
|
return isTauriRuntime();
|
|
}
|