- Add intelligent-router.sh hook for automatic agent routing - Add AUTO-TRIGGER-SUMMARY.md documentation - Add FINAL-INTEGRATION-SUMMARY.md documentation - Complete Prometheus integration (6 commands + 4 tools) - Complete Dexto integration (12 commands + 5 tools) - Enhanced Ralph with access to all agents - Fix /clawd command (removed disable-model-invocation) - Update hooks.json to v5 with intelligent routing - 291 total skills now available - All 21 commands with automatic routing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
406 lines
15 KiB
TypeScript
406 lines
15 KiB
TypeScript
import React, { useRef, useEffect, useCallback } from 'react';
|
|
import type { SanitizedToolResult } from '@dexto/core';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { useAnalytics } from '@/lib/analytics/index.js';
|
|
import { client } from '@/lib/client.js';
|
|
import { queryKeys } from '@/lib/queryKeys.js';
|
|
import { createMessageStream } from '@dexto/client-sdk';
|
|
import type { MessageStreamEvent } from '@dexto/client-sdk';
|
|
import { eventBus } from '@/lib/events/EventBus.js';
|
|
import { useChatStore } from '@/lib/stores/chatStore.js';
|
|
import type { Session } from './useSessions.js';
|
|
|
|
// Content part types - import from centralized types.ts
|
|
import type { FileData } from '../../types.js';
|
|
|
|
// Tool result types
|
|
export interface ToolResultError {
|
|
error: string | Record<string, unknown>;
|
|
}
|
|
|
|
export type ToolResultContent = SanitizedToolResult;
|
|
|
|
export type ToolResult = ToolResultError | SanitizedToolResult | string | Record<string, unknown>;
|
|
|
|
// Type guards for tool results
|
|
export function isToolResultError(result: unknown): result is ToolResultError {
|
|
return typeof result === 'object' && result !== null && 'error' in result;
|
|
}
|
|
|
|
export function isToolResultContent(result: unknown): result is ToolResultContent {
|
|
return (
|
|
typeof result === 'object' &&
|
|
result !== null &&
|
|
'content' in result &&
|
|
Array.isArray((result as ToolResultContent).content)
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Re-export Message types from chatStore (single source of truth)
|
|
// =============================================================================
|
|
|
|
// Import from chatStore
|
|
import type { Message } from '@/lib/stores/chatStore.js';
|
|
|
|
// Re-export for API compatibility - components can import these from either place
|
|
export type { Message, ErrorMessage } from '@/lib/stores/chatStore.js';
|
|
|
|
// Legacy type aliases for code that uses the discriminated union pattern
|
|
// These are intersection types that narrow the Message type by role
|
|
export type UIUserMessage = Message & { role: 'user' };
|
|
export type UIAssistantMessage = Message & { role: 'assistant' };
|
|
export type UIToolMessage = Message & { role: 'tool' };
|
|
|
|
// =============================================================================
|
|
// Message Type Guards
|
|
// =============================================================================
|
|
|
|
/** Type guard for user messages */
|
|
export function isUserMessage(msg: Message): msg is UIUserMessage {
|
|
return msg.role === 'user';
|
|
}
|
|
|
|
/** Type guard for assistant messages */
|
|
export function isAssistantMessage(msg: Message): msg is UIAssistantMessage {
|
|
return msg.role === 'assistant';
|
|
}
|
|
|
|
/** Type guard for tool messages */
|
|
export function isToolMessage(msg: Message): msg is UIToolMessage {
|
|
return msg.role === 'tool';
|
|
}
|
|
|
|
export type StreamStatus = 'idle' | 'connecting' | 'open' | 'closed';
|
|
|
|
const generateUniqueId = () => `msg-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
|
|
export function useChat(
|
|
activeSessionIdRef: React.MutableRefObject<string | null>,
|
|
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>
|
|
) {
|
|
const analytics = useAnalytics();
|
|
const analyticsRef = useRef(analytics);
|
|
const queryClient = useQueryClient();
|
|
|
|
// Helper to update sessions cache (replaces DOM events)
|
|
const updateSessionActivity = useCallback(
|
|
(sessionId: string, incrementMessageCount: boolean = true) => {
|
|
queryClient.setQueryData<Session[]>(queryKeys.sessions.all, (old = []) => {
|
|
const exists = old.some((s) => s.id === sessionId);
|
|
if (exists) {
|
|
return old.map((session) =>
|
|
session.id === sessionId
|
|
? {
|
|
...session,
|
|
...(incrementMessageCount && {
|
|
messageCount: session.messageCount + 1,
|
|
}),
|
|
lastActivity: Date.now(),
|
|
}
|
|
: session
|
|
);
|
|
} else {
|
|
// Create new session entry
|
|
const newSession: Session = {
|
|
id: sessionId,
|
|
createdAt: Date.now(),
|
|
lastActivity: Date.now(),
|
|
messageCount: 1,
|
|
title: null,
|
|
};
|
|
return [newSession, ...old];
|
|
}
|
|
});
|
|
},
|
|
[queryClient]
|
|
);
|
|
|
|
const updateSessionTitle = useCallback(
|
|
(sessionId: string, title: string) => {
|
|
queryClient.setQueryData<Session[]>(queryKeys.sessions.all, (old = []) =>
|
|
old.map((session) => (session.id === sessionId ? { ...session, title } : session))
|
|
);
|
|
},
|
|
[queryClient]
|
|
);
|
|
|
|
// Track message IDs for error anchoring
|
|
const lastUserMessageIdRef = useRef<string | null>(null);
|
|
const lastMessageIdRef = useRef<string | null>(null);
|
|
// Map callId to message index for O(1) tool result pairing
|
|
const pendingToolCallsRef = useRef<Map<string, number>>(new Map());
|
|
|
|
// Keep analytics ref updated
|
|
useEffect(() => {
|
|
analyticsRef.current = analytics;
|
|
}, [analytics]);
|
|
|
|
// Abort controller management (moved from ChatContext)
|
|
const getAbortController = useCallback(
|
|
(sessionId: string): AbortController => {
|
|
const existing = abortControllersRef.current.get(sessionId);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
const controller = new AbortController();
|
|
abortControllersRef.current.set(sessionId, controller);
|
|
return controller;
|
|
},
|
|
[abortControllersRef]
|
|
);
|
|
|
|
const abortSession = useCallback(
|
|
(sessionId: string) => {
|
|
const controller = abortControllersRef.current.get(sessionId);
|
|
if (controller) {
|
|
controller.abort();
|
|
abortControllersRef.current.delete(sessionId);
|
|
}
|
|
},
|
|
[abortControllersRef]
|
|
);
|
|
|
|
const isForActiveSession = useCallback(
|
|
(sessionId?: string): boolean => {
|
|
if (!sessionId) return false;
|
|
const current = activeSessionIdRef.current;
|
|
return !!current && sessionId === current;
|
|
},
|
|
[activeSessionIdRef]
|
|
);
|
|
|
|
const processEvent = useCallback(
|
|
(event: MessageStreamEvent) => {
|
|
// All streaming events must have sessionId
|
|
if (!event.sessionId) {
|
|
console.error(`Event missing sessionId: ${JSON.stringify(event)}`);
|
|
return;
|
|
}
|
|
|
|
// Check session match
|
|
if (!isForActiveSession(event.sessionId)) {
|
|
return;
|
|
}
|
|
|
|
// Dispatch to EventBus - handlers.ts will update chatStore
|
|
// NOTE: All store updates (messages, streaming, processing) are handled by handlers.ts
|
|
// This function only handles React-specific side effects not in handlers.ts:
|
|
// - TanStack Query cache updates
|
|
// - Analytics tracking
|
|
// - Ref updates for error anchoring
|
|
eventBus.dispatch(event);
|
|
|
|
// Handle React-specific side effects not in handlers.ts
|
|
// IMPORTANT: Do NOT update chatStore here - that's handled by handlers.ts via EventBus
|
|
switch (event.name) {
|
|
case 'llm:response': {
|
|
// Update sessions cache (response received)
|
|
updateSessionActivity(event.sessionId);
|
|
break;
|
|
}
|
|
|
|
case 'llm:tool-call': {
|
|
const { toolName, sessionId } = event;
|
|
|
|
// Track tool called analytics
|
|
if (toolName) {
|
|
analyticsRef.current.trackToolCalled({
|
|
toolName,
|
|
sessionId,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'llm:tool-result': {
|
|
const { toolName, success } = event;
|
|
|
|
// Track tool result analytics
|
|
if (toolName) {
|
|
analyticsRef.current.trackToolResult({
|
|
toolName,
|
|
success: success !== false,
|
|
sessionId: event.sessionId,
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'session:title-updated': {
|
|
// Update TanStack Query cache
|
|
updateSessionTitle(event.sessionId, event.title);
|
|
break;
|
|
}
|
|
|
|
case 'message:dequeued': {
|
|
// Update sessions cache
|
|
updateSessionActivity(event.sessionId);
|
|
|
|
// Invalidate queue cache so UI removes the message
|
|
queryClient.invalidateQueries({
|
|
queryKey: queryKeys.queue.list(event.sessionId),
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
},
|
|
[isForActiveSession, analyticsRef, updateSessionActivity, updateSessionTitle, queryClient]
|
|
);
|
|
|
|
const sendMessage = useCallback(
|
|
async (
|
|
content: string,
|
|
imageData?: { image: string; mimeType: string },
|
|
fileData?: FileData,
|
|
sessionId?: string
|
|
) => {
|
|
if (!sessionId) {
|
|
console.error('Session ID required for sending message');
|
|
return;
|
|
}
|
|
|
|
// Abort previous request if any
|
|
abortSession(sessionId);
|
|
|
|
const abortController = getAbortController(sessionId) || new AbortController();
|
|
|
|
useChatStore.getState().setProcessing(sessionId, true);
|
|
|
|
// Add user message to state
|
|
const userId = generateUniqueId();
|
|
lastUserMessageIdRef.current = userId;
|
|
lastMessageIdRef.current = userId; // Track for error anchoring
|
|
useChatStore.getState().addMessage(sessionId, {
|
|
id: userId,
|
|
role: 'user',
|
|
content,
|
|
createdAt: Date.now(),
|
|
sessionId,
|
|
imageData,
|
|
fileData,
|
|
});
|
|
|
|
// Update sessions cache (user message sent)
|
|
updateSessionActivity(sessionId);
|
|
|
|
try {
|
|
// Build content parts array from text, image, and file data
|
|
// New API uses unified ContentInput = string | ContentPart[]
|
|
const contentParts: Array<
|
|
| { type: 'text'; text: string }
|
|
| { type: 'image'; image: string; mimeType?: string }
|
|
| { type: 'file'; data: string; mimeType: string; filename?: string }
|
|
> = [];
|
|
|
|
if (content) {
|
|
contentParts.push({ type: 'text', text: content });
|
|
}
|
|
if (imageData) {
|
|
contentParts.push({
|
|
type: 'image',
|
|
image: imageData.image,
|
|
mimeType: imageData.mimeType,
|
|
});
|
|
}
|
|
if (fileData) {
|
|
contentParts.push({
|
|
type: 'file',
|
|
data: fileData.data,
|
|
mimeType: fileData.mimeType,
|
|
filename: fileData.filename,
|
|
});
|
|
}
|
|
|
|
// Always use SSE for all events (tool calls, approvals, responses)
|
|
// The 'stream' flag only controls whether chunks update UI incrementally
|
|
const responsePromise = client.api['message-stream'].$post({
|
|
json: {
|
|
content:
|
|
contentParts.length === 1 && contentParts[0]?.type === 'text'
|
|
? content // Simple text-only case: send as string
|
|
: contentParts, // Multimodal: send as array
|
|
sessionId,
|
|
},
|
|
});
|
|
|
|
const iterator = createMessageStream(responsePromise, {
|
|
signal: abortController.signal,
|
|
});
|
|
|
|
for await (const event of iterator) {
|
|
processEvent(event);
|
|
}
|
|
} catch (error: unknown) {
|
|
// Handle abort gracefully
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
console.log('Stream aborted by user');
|
|
return;
|
|
}
|
|
|
|
console.error(
|
|
`Stream error: ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
useChatStore.getState().setProcessing(sessionId, false);
|
|
|
|
const message = error instanceof Error ? error.message : 'Failed to send message';
|
|
useChatStore.getState().setError(sessionId, {
|
|
id: generateUniqueId(),
|
|
message,
|
|
timestamp: Date.now(),
|
|
context: 'stream',
|
|
recoverable: true,
|
|
sessionId,
|
|
anchorMessageId: lastMessageIdRef.current || undefined,
|
|
});
|
|
}
|
|
},
|
|
[processEvent, abortSession, getAbortController, updateSessionActivity]
|
|
);
|
|
|
|
const reset = useCallback(async (sessionId?: string) => {
|
|
if (!sessionId) return;
|
|
|
|
try {
|
|
await client.api.reset.$post({
|
|
json: { sessionId },
|
|
});
|
|
} catch (e) {
|
|
console.error(`Failed to reset session: ${e instanceof Error ? e.message : String(e)}`);
|
|
}
|
|
|
|
// Note: Messages are now in chatStore, not local state
|
|
useChatStore.getState().setError(sessionId, null);
|
|
lastUserMessageIdRef.current = null;
|
|
lastMessageIdRef.current = null;
|
|
pendingToolCallsRef.current.clear();
|
|
useChatStore.getState().setProcessing(sessionId, false);
|
|
}, []);
|
|
|
|
const cancel = useCallback(async (sessionId?: string, clearQueue: boolean = false) => {
|
|
if (!sessionId) return;
|
|
|
|
// Tell server to cancel the LLM stream
|
|
// Soft cancel (default): Only cancel current response, queued messages continue
|
|
// Hard cancel (clearQueue=true): Cancel current response AND clear all queued messages
|
|
try {
|
|
await client.api.sessions[':sessionId'].cancel.$post({
|
|
param: { sessionId },
|
|
json: { clearQueue },
|
|
});
|
|
} catch (err) {
|
|
// Server cancel is best-effort - log but don't throw
|
|
console.warn('Failed to cancel server-side:', err);
|
|
}
|
|
|
|
// UI state will be updated when server sends run:complete event
|
|
pendingToolCallsRef.current.clear();
|
|
}, []);
|
|
|
|
return {
|
|
sendMessage,
|
|
reset,
|
|
cancel,
|
|
};
|
|
}
|