feat: Add intelligent auto-router and enhanced integrations
- 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>
This commit is contained in:
71
dexto/packages/webui/components/hooks/ApprovalContext.tsx
Normal file
71
dexto/packages/webui/components/hooks/ApprovalContext.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Approval Context
|
||||
*
|
||||
* Provides approval handling functionality via React Context.
|
||||
* Wraps the approvalStore to provide a clean API for components.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useCallback, type ReactNode } from 'react';
|
||||
import { useApprovalStore } from '@/lib/stores/approvalStore';
|
||||
import type { ApprovalRequest } from '@dexto/core';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface ApprovalContextType {
|
||||
/**
|
||||
* Handle an incoming approval request (add to store)
|
||||
*/
|
||||
handleApprovalRequest: (request: ApprovalRequest) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Context
|
||||
// =============================================================================
|
||||
|
||||
const ApprovalContext = createContext<ApprovalContextType | null>(null);
|
||||
|
||||
// =============================================================================
|
||||
// Provider
|
||||
// =============================================================================
|
||||
|
||||
interface ApprovalProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ApprovalProvider({ children }: ApprovalProviderProps) {
|
||||
const addApproval = useApprovalStore((s) => s.addApproval);
|
||||
|
||||
const handleApprovalRequest = useCallback(
|
||||
(request: ApprovalRequest) => {
|
||||
addApproval(request);
|
||||
},
|
||||
[addApproval]
|
||||
);
|
||||
|
||||
return (
|
||||
<ApprovalContext.Provider value={{ handleApprovalRequest }}>
|
||||
{children}
|
||||
</ApprovalContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hook
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Hook to access approval handling functions
|
||||
*
|
||||
* @throws Error if used outside ApprovalProvider
|
||||
*/
|
||||
export function useApproval(): ApprovalContextType {
|
||||
const context = useContext(ApprovalContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useApproval must be used within an ApprovalProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
687
dexto/packages/webui/components/hooks/ChatContext.tsx
Normal file
687
dexto/packages/webui/components/hooks/ChatContext.tsx
Normal file
@@ -0,0 +1,687 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useChat, Message, UIUserMessage, UIAssistantMessage, UIToolMessage } from './useChat';
|
||||
import { useApproval } from './ApprovalContext';
|
||||
import { usePendingApprovals } from './useApprovals';
|
||||
import type { FilePart, ImagePart, TextPart, UIResourcePart } from '../../types';
|
||||
import type { SanitizedToolResult, ApprovalRequest } from '@dexto/core';
|
||||
import { getResourceKind } from '@dexto/core';
|
||||
import { useAnalytics } from '@/lib/analytics/index.js';
|
||||
import { queryKeys } from '@/lib/queryKeys.js';
|
||||
import { client } from '@/lib/client.js';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import {
|
||||
useAgentStore,
|
||||
useSessionStore,
|
||||
useChatStore,
|
||||
useCurrentSessionId,
|
||||
useIsWelcomeState,
|
||||
useSessionMessages,
|
||||
} from '@/lib/stores/index.js';
|
||||
|
||||
// Helper to get history endpoint type (workaround for string literal path)
|
||||
type HistoryEndpoint = (typeof client.api.sessions)[':sessionId']['history'];
|
||||
|
||||
// Derive history message type from Hono client response (server schema is source of truth)
|
||||
type HistoryResponse = Awaited<ReturnType<HistoryEndpoint['$get']>>;
|
||||
type HistoryData = Awaited<ReturnType<Extract<HistoryResponse, { ok: true }>['json']>>;
|
||||
type HistoryMessage = HistoryData['history'][number];
|
||||
|
||||
// Derive toolCall type from HistoryMessage
|
||||
type ToolCall = NonNullable<HistoryMessage['toolCalls']>[number];
|
||||
|
||||
interface ChatContextType {
|
||||
messages: Message[];
|
||||
sendMessage: (
|
||||
content: string,
|
||||
imageData?: { image: string; mimeType: string },
|
||||
fileData?: { data: string; mimeType: string; filename?: string }
|
||||
) => void;
|
||||
reset: () => void;
|
||||
switchSession: (sessionId: string) => void;
|
||||
loadSessionHistory: (sessionId: string) => Promise<void>;
|
||||
returnToWelcome: () => void;
|
||||
cancel: (sessionId?: string) => void;
|
||||
}
|
||||
|
||||
// Helper function to fetch and convert session history to UI messages
|
||||
async function fetchSessionHistory(
|
||||
sessionId: string
|
||||
): Promise<{ messages: Message[]; isBusy: boolean }> {
|
||||
const response = await client.api.sessions[':sessionId'].history.$get({
|
||||
param: { sessionId },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch session history');
|
||||
}
|
||||
const data = await response.json();
|
||||
const history = data.history || [];
|
||||
return {
|
||||
messages: convertHistoryToMessages(history, sessionId),
|
||||
isBusy: data.isBusy ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to convert session history API response to UI messages
|
||||
function convertHistoryToMessages(history: HistoryMessage[], sessionId: string): Message[] {
|
||||
const uiMessages: Message[] = [];
|
||||
const pendingToolCalls = new Map<string, number>();
|
||||
|
||||
for (let index = 0; index < history.length; index++) {
|
||||
const msg = history[index];
|
||||
const createdAt = msg.timestamp ?? Date.now() - (history.length - index) * 1000;
|
||||
const baseId = `session-${sessionId}-${index}`;
|
||||
|
||||
// Skip system messages - they're not shown in UI
|
||||
if (msg.role === 'system') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const deriveResources = (
|
||||
content: Array<TextPart | ImagePart | FilePart | UIResourcePart>
|
||||
): SanitizedToolResult['resources'] => {
|
||||
const resources: NonNullable<SanitizedToolResult['resources']> = [];
|
||||
|
||||
for (const part of content) {
|
||||
if (
|
||||
part.type === 'image' &&
|
||||
typeof part.image === 'string' &&
|
||||
part.image.startsWith('@blob:')
|
||||
) {
|
||||
const uri = part.image.substring(1);
|
||||
resources.push({
|
||||
uri,
|
||||
kind: 'image',
|
||||
mimeType: part.mimeType ?? 'image/jpeg',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
part.type === 'file' &&
|
||||
typeof part.data === 'string' &&
|
||||
part.data.startsWith('@blob:')
|
||||
) {
|
||||
const uri = part.data.substring(1);
|
||||
const mimeType = part.mimeType ?? 'application/octet-stream';
|
||||
const kind = getResourceKind(mimeType);
|
||||
|
||||
resources.push({
|
||||
uri,
|
||||
kind,
|
||||
mimeType,
|
||||
...(part.filename ? { filename: part.filename } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return resources.length > 0 ? resources : undefined;
|
||||
};
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
// Create assistant message
|
||||
if (msg.content) {
|
||||
// Extract text content from string or ContentPart array
|
||||
let textContent: string | null = null;
|
||||
if (typeof msg.content === 'string') {
|
||||
textContent = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
// Extract text from ContentPart array
|
||||
const textParts = msg.content
|
||||
.filter((part): part is TextPart => part.type === 'text')
|
||||
.map((part) => part.text);
|
||||
textContent = textParts.length > 0 ? textParts.join('\n') : null;
|
||||
}
|
||||
|
||||
const assistantMessage: UIAssistantMessage = {
|
||||
id: baseId,
|
||||
role: 'assistant',
|
||||
content: textContent,
|
||||
createdAt,
|
||||
sessionId,
|
||||
tokenUsage: msg.tokenUsage,
|
||||
reasoning: msg.reasoning,
|
||||
model: msg.model,
|
||||
provider: msg.provider,
|
||||
};
|
||||
uiMessages.push(assistantMessage);
|
||||
}
|
||||
|
||||
// Create tool messages for tool calls
|
||||
if (msg.toolCalls && msg.toolCalls.length > 0) {
|
||||
msg.toolCalls.forEach((toolCall: ToolCall, toolIndex: number) => {
|
||||
let toolArgs: Record<string, unknown> = {};
|
||||
if (toolCall?.function) {
|
||||
try {
|
||||
toolArgs = JSON.parse(toolCall.function.arguments || '{}');
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`Failed to parse toolCall arguments for ${toolCall.function?.name || 'unknown'}: ${e}`
|
||||
);
|
||||
toolArgs = {};
|
||||
}
|
||||
}
|
||||
const toolName = toolCall.function?.name || 'unknown';
|
||||
|
||||
const toolMessage: UIToolMessage = {
|
||||
id: `${baseId}-tool-${toolIndex}`,
|
||||
role: 'tool',
|
||||
content: null,
|
||||
createdAt: createdAt + toolIndex,
|
||||
sessionId,
|
||||
toolName,
|
||||
toolArgs,
|
||||
toolCallId: toolCall.id,
|
||||
};
|
||||
|
||||
if (typeof toolCall.id === 'string' && toolCall.id.length > 0) {
|
||||
pendingToolCalls.set(toolCall.id, uiMessages.length);
|
||||
}
|
||||
|
||||
uiMessages.push(toolMessage);
|
||||
});
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === 'tool') {
|
||||
const toolCallId = typeof msg.toolCallId === 'string' ? msg.toolCallId : undefined;
|
||||
const toolName = typeof msg.name === 'string' ? msg.name : 'unknown';
|
||||
const normalizedContent: Array<TextPart | ImagePart | FilePart> = Array.isArray(
|
||||
msg.content
|
||||
)
|
||||
? (msg.content as Array<TextPart | ImagePart | FilePart>)
|
||||
: typeof msg.content === 'string'
|
||||
? [{ type: 'text', text: msg.content }]
|
||||
: [];
|
||||
|
||||
const inferredResources = deriveResources(normalizedContent);
|
||||
// Extract success status from stored message (defaults to true for backwards compatibility)
|
||||
const success =
|
||||
'success' in msg && typeof msg.success === 'boolean' ? msg.success : true;
|
||||
const sanitizedFromHistory: SanitizedToolResult = {
|
||||
content: normalizedContent,
|
||||
...(inferredResources ? { resources: inferredResources } : {}),
|
||||
meta: {
|
||||
toolName,
|
||||
toolCallId: toolCallId ?? `tool-${index}`,
|
||||
success,
|
||||
},
|
||||
};
|
||||
|
||||
// Extract approval metadata if present (type-safe with optional chaining)
|
||||
const requireApproval: boolean | undefined =
|
||||
'requireApproval' in msg && typeof msg.requireApproval === 'boolean'
|
||||
? msg.requireApproval
|
||||
: undefined;
|
||||
const approvalStatus: 'pending' | 'approved' | 'rejected' | undefined =
|
||||
'approvalStatus' in msg &&
|
||||
(msg.approvalStatus === 'pending' ||
|
||||
msg.approvalStatus === 'approved' ||
|
||||
msg.approvalStatus === 'rejected')
|
||||
? msg.approvalStatus
|
||||
: undefined;
|
||||
|
||||
if (toolCallId && pendingToolCalls.has(toolCallId)) {
|
||||
// Update existing tool message with result
|
||||
const messageIndex = pendingToolCalls.get(toolCallId)!;
|
||||
const existingMessage = uiMessages[messageIndex] as UIToolMessage;
|
||||
uiMessages[messageIndex] = {
|
||||
...existingMessage,
|
||||
toolResult: sanitizedFromHistory,
|
||||
toolResultMeta: sanitizedFromHistory.meta,
|
||||
toolResultSuccess: sanitizedFromHistory.meta?.success,
|
||||
...(requireApproval !== undefined && { requireApproval }),
|
||||
...(approvalStatus !== undefined && { approvalStatus }),
|
||||
};
|
||||
} else {
|
||||
// Create new tool message with result
|
||||
const toolMessage: UIToolMessage = {
|
||||
id: baseId,
|
||||
role: 'tool',
|
||||
content: null,
|
||||
createdAt,
|
||||
sessionId,
|
||||
toolName,
|
||||
toolCallId,
|
||||
toolResult: sanitizedFromHistory,
|
||||
toolResultMeta: sanitizedFromHistory.meta,
|
||||
toolResultSuccess: sanitizedFromHistory.meta?.success,
|
||||
...(requireApproval !== undefined && { requireApproval }),
|
||||
...(approvalStatus !== undefined && { approvalStatus }),
|
||||
};
|
||||
uiMessages.push(toolMessage);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// User message (only remaining case after system/assistant/tool handled)
|
||||
if (msg.role === 'user') {
|
||||
const userMessage: UIUserMessage = {
|
||||
id: baseId,
|
||||
role: 'user',
|
||||
content: msg.content,
|
||||
createdAt,
|
||||
sessionId,
|
||||
};
|
||||
uiMessages.push(userMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark any tool calls that never received results as failed (incomplete)
|
||||
// This happens when a run was interrupted or crashed before tool completion
|
||||
for (const [_callId, messageIndex] of pendingToolCalls) {
|
||||
const msg = uiMessages[messageIndex];
|
||||
if (msg && msg.role === 'tool' && msg.toolResult === undefined) {
|
||||
uiMessages[messageIndex] = {
|
||||
...msg,
|
||||
toolResultSuccess: false, // Mark as failed so UI doesn't show "running"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return uiMessages;
|
||||
}
|
||||
|
||||
const ChatContext = createContext<ChatContextType | undefined>(undefined);
|
||||
|
||||
export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
const analytics = useAnalytics();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Get state from stores using centralized selectors
|
||||
const currentSessionId = useCurrentSessionId();
|
||||
const isWelcomeState = useIsWelcomeState();
|
||||
|
||||
// Local state for UI flow control only (not shared/persisted state)
|
||||
const [isSwitchingSession, setIsSwitchingSession] = useState(false); // Guard against rapid session switches
|
||||
const [isCreatingSession, setIsCreatingSession] = useState(false); // Guard against double auto-creation
|
||||
const lastSwitchedSessionRef = useRef<string | null>(null); // Track last switched session to prevent duplicate switches
|
||||
const newSessionWithMessageRef = useRef<string | null>(null); // Track new sessions that already have first message sent
|
||||
const currentSessionIdRef = useRef<string | null>(null); // Synchronous session ID (updates before React state to prevent race conditions)
|
||||
|
||||
// Session abort controllers for cancellation
|
||||
const sessionAbortControllersRef = useRef<Map<string, AbortController>>(new Map());
|
||||
|
||||
// useChat hook manages abort controllers internally
|
||||
const {
|
||||
sendMessage: originalSendMessage,
|
||||
reset: originalReset,
|
||||
cancel,
|
||||
} = useChat(currentSessionIdRef, sessionAbortControllersRef);
|
||||
|
||||
// Restore pending approvals when session changes (e.g., after page refresh)
|
||||
const { handleApprovalRequest } = useApproval();
|
||||
const { data: pendingApprovalsData } = usePendingApprovals(currentSessionId);
|
||||
const restoredApprovalsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingApprovalsData?.approvals || pendingApprovalsData.approvals.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore any pending approvals that haven't been restored yet
|
||||
for (const approval of pendingApprovalsData.approvals) {
|
||||
// Skip if we've already restored this approval
|
||||
if (restoredApprovalsRef.current.has(approval.approvalId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark as restored before calling handler to prevent duplicates
|
||||
restoredApprovalsRef.current.add(approval.approvalId);
|
||||
|
||||
// Convert API response format to ApprovalRequest format
|
||||
// TODO: The API returns a simplified format without full metadata because
|
||||
// ApprovalCoordinator only tracks approval IDs, not the full request data.
|
||||
// To fix properly: store full ApprovalRequest in ApprovalCoordinator when
|
||||
// requests are created, then return that data from GET /api/approvals.
|
||||
handleApprovalRequest({
|
||||
approvalId: approval.approvalId,
|
||||
type: approval.type,
|
||||
sessionId: approval.sessionId,
|
||||
timeout: approval.timeout,
|
||||
timestamp: new Date(approval.timestamp),
|
||||
metadata: approval.metadata,
|
||||
} as ApprovalRequest);
|
||||
}
|
||||
}, [pendingApprovalsData, handleApprovalRequest]);
|
||||
|
||||
// Clear restored approvals tracking when session changes
|
||||
useEffect(() => {
|
||||
restoredApprovalsRef.current.clear();
|
||||
}, [currentSessionId]);
|
||||
|
||||
// Messages from centralized selector (stable reference, handles null session)
|
||||
const messages = useSessionMessages(currentSessionId);
|
||||
|
||||
// Mutation for generating session title
|
||||
const { mutate: generateTitle } = useMutation({
|
||||
mutationFn: async (sessionId: string) => {
|
||||
const response = await client.api.sessions[':sessionId']['generate-title'].$post({
|
||||
param: { sessionId },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to generate title');
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.title;
|
||||
},
|
||||
onSuccess: (_title, sessionId) => {
|
||||
// Invalidate sessions query to show the new title
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
|
||||
// Also invalidate the specific session query if needed
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.detail(sessionId) });
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-create session on first message with random UUID
|
||||
const createAutoSession = useCallback(async (): Promise<string> => {
|
||||
const response = await client.api.sessions.$post({
|
||||
json: {}, // Let server generate random UUID
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create session');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.session?.id) {
|
||||
throw new Error('Session ID not found in server response');
|
||||
}
|
||||
|
||||
const sessionId = data.session.id;
|
||||
|
||||
// Track session creation
|
||||
analytics.trackSessionCreated({
|
||||
sessionId,
|
||||
trigger: 'first_message',
|
||||
});
|
||||
|
||||
return sessionId;
|
||||
}, [analytics]);
|
||||
|
||||
// Enhanced sendMessage with auto-session creation
|
||||
const sendMessage = useCallback(
|
||||
async (
|
||||
content: string,
|
||||
imageData?: { image: string; mimeType: string },
|
||||
fileData?: { data: string; mimeType: string; filename?: string }
|
||||
) => {
|
||||
let sessionId = currentSessionId;
|
||||
let isNewSession = false;
|
||||
|
||||
// Auto-create session on first message and wait for it to complete
|
||||
if (!sessionId && isWelcomeState) {
|
||||
if (isCreatingSession) return; // Another send in-flight; drop duplicate request
|
||||
try {
|
||||
setIsCreatingSession(true);
|
||||
sessionId = await createAutoSession();
|
||||
isNewSession = true;
|
||||
|
||||
// Mark this session as a new session before navigation
|
||||
// This allows switchSession to run but skip history load
|
||||
newSessionWithMessageRef.current = sessionId;
|
||||
|
||||
// Update ref BEFORE streaming to prevent race conditions
|
||||
currentSessionIdRef.current = sessionId;
|
||||
|
||||
// Send message BEFORE navigating
|
||||
originalSendMessage(content, imageData, fileData, sessionId);
|
||||
|
||||
// Navigate - this will trigger switchSession via ChatApp useEffect
|
||||
navigate({ to: `/chat/${sessionId}`, replace: true });
|
||||
|
||||
// Generate title for newly created session after first message
|
||||
// Use setTimeout to delay title generation until message is complete
|
||||
setTimeout(() => {
|
||||
if (sessionId) generateTitle(sessionId);
|
||||
}, 0);
|
||||
|
||||
// Note: currentLLM will automatically refetch when currentSessionId changes
|
||||
} catch (error) {
|
||||
console.error('Failed to create session:', error);
|
||||
return; // Don't send message if session creation fails
|
||||
} finally {
|
||||
setIsCreatingSession(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Only send if we're using an existing session (not a newly created one)
|
||||
if (sessionId && !isNewSession) {
|
||||
originalSendMessage(content, imageData, fileData, sessionId);
|
||||
}
|
||||
|
||||
// Track message sent
|
||||
if (sessionId) {
|
||||
analytics.trackMessageSent({
|
||||
sessionId,
|
||||
provider: 'unknown', // Provider/model tracking moved to component level
|
||||
model: 'unknown',
|
||||
hasImage: !!imageData,
|
||||
hasFile: !!fileData,
|
||||
messageLength: content.length,
|
||||
});
|
||||
} else {
|
||||
console.error('No session available for sending message');
|
||||
}
|
||||
},
|
||||
[
|
||||
originalSendMessage,
|
||||
currentSessionId,
|
||||
isWelcomeState,
|
||||
isCreatingSession,
|
||||
createAutoSession,
|
||||
navigate,
|
||||
analytics,
|
||||
generateTitle,
|
||||
]
|
||||
);
|
||||
|
||||
// Enhanced reset with session support
|
||||
const reset = useCallback(() => {
|
||||
if (currentSessionId) {
|
||||
// Track conversation reset
|
||||
const messageCount = messages.filter((m) => m.sessionId === currentSessionId).length;
|
||||
analytics.trackSessionReset({
|
||||
sessionId: currentSessionId,
|
||||
messageCount,
|
||||
});
|
||||
|
||||
originalReset(currentSessionId);
|
||||
}
|
||||
}, [originalReset, currentSessionId, analytics, messages]);
|
||||
|
||||
// Load session history when switching sessions
|
||||
const { data: sessionHistoryData } = useQuery({
|
||||
queryKey: queryKeys.sessions.history(currentSessionId || ''),
|
||||
queryFn: async () => {
|
||||
if (!currentSessionId) {
|
||||
return { messages: [], isBusy: false };
|
||||
}
|
||||
try {
|
||||
return await fetchSessionHistory(currentSessionId);
|
||||
} catch {
|
||||
// Return empty result for 404 or other errors
|
||||
return { messages: [], isBusy: false };
|
||||
}
|
||||
},
|
||||
enabled: false, // Manual refetch only
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Sync session history data to messages when it changes
|
||||
useEffect(() => {
|
||||
if (sessionHistoryData && currentSessionId) {
|
||||
const currentMessages = useChatStore.getState().getMessages(currentSessionId);
|
||||
const hasSessionMsgs = currentMessages.some((m) => m.sessionId === currentSessionId);
|
||||
if (!hasSessionMsgs) {
|
||||
useChatStore
|
||||
.getState()
|
||||
.setMessages(currentSessionId, sessionHistoryData.messages as any);
|
||||
}
|
||||
// Cancel any active run on page refresh (we can't reconnect to the stream)
|
||||
if (sessionHistoryData.isBusy) {
|
||||
// Reset agent state since we're cancelling - we won't receive run:complete event
|
||||
useAgentStore.getState().setIdle();
|
||||
client.api.sessions[':sessionId'].cancel
|
||||
.$post({
|
||||
param: { sessionId: currentSessionId },
|
||||
json: { clearQueue: true },
|
||||
})
|
||||
.catch((e) => console.warn('Failed to cancel busy session:', e));
|
||||
}
|
||||
}
|
||||
}, [sessionHistoryData, currentSessionId]);
|
||||
|
||||
const loadSessionHistory = useCallback(
|
||||
async (sessionId: string) => {
|
||||
try {
|
||||
const result = await queryClient.fetchQuery({
|
||||
queryKey: queryKeys.sessions.history(sessionId),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await fetchSessionHistory(sessionId);
|
||||
} catch {
|
||||
// Return empty result for 404 or other errors
|
||||
return { messages: [], isBusy: false };
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const currentMessages = useChatStore.getState().getMessages(sessionId);
|
||||
const hasSessionMsgs = currentMessages.some((m) => m.sessionId === sessionId);
|
||||
if (!hasSessionMsgs) {
|
||||
// Populate chatStore with history (cast to compatible type)
|
||||
useChatStore.getState().initFromHistory(sessionId, result.messages as any);
|
||||
}
|
||||
|
||||
// Cancel any active run on page refresh (we can't reconnect to the stream)
|
||||
// This ensures clean state - user can see history and send new messages
|
||||
// TODO: Implement stream reconnection instead of cancelling
|
||||
// - Add GET /sessions/{sessionId}/events SSE endpoint for listen-only mode
|
||||
// - Connect to event stream when isBusy=true to resume receiving updates
|
||||
if (result.isBusy) {
|
||||
// Reset agent state since we're cancelling - we won't receive run:complete event
|
||||
useAgentStore.getState().setIdle();
|
||||
try {
|
||||
await client.api.sessions[':sessionId'].cancel.$post({
|
||||
param: { sessionId },
|
||||
json: { clearQueue: true }, // Hard cancel - clear queue too
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to cancel busy session on load:', e);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading session history:', error);
|
||||
useChatStore.getState().clearMessages(sessionId);
|
||||
}
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
// Switch to a different session and load it on the backend
|
||||
const switchSession = useCallback(
|
||||
async (sessionId: string) => {
|
||||
// Guard against switching to same session or rapid successive switches
|
||||
// Use ref for immediate check (state updates are async)
|
||||
if (
|
||||
sessionId === currentSessionId ||
|
||||
sessionId === lastSwitchedSessionRef.current ||
|
||||
isSwitchingSession
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSwitchingSession(true);
|
||||
try {
|
||||
// Track session switch (defensive - analytics failures shouldn't block switching)
|
||||
try {
|
||||
analytics.trackSessionSwitched({
|
||||
fromSessionId: currentSessionId,
|
||||
toSessionId: sessionId,
|
||||
});
|
||||
} catch (analyticsError) {
|
||||
console.error('Failed to track session switch:', analyticsError);
|
||||
}
|
||||
|
||||
// Skip history load for newly created sessions with first message already sent
|
||||
// This prevents replacing message IDs and breaking error anchoring
|
||||
// TODO: Long-term fix - backend should generate and persist message IDs
|
||||
// so history reload doesn't cause ID mismatches
|
||||
const skipHistoryLoad = newSessionWithMessageRef.current === sessionId;
|
||||
if (skipHistoryLoad) {
|
||||
// Clear the ref after using it once
|
||||
newSessionWithMessageRef.current = null;
|
||||
}
|
||||
|
||||
// Update ref BEFORE store to prevent race conditions with streaming
|
||||
currentSessionIdRef.current = sessionId;
|
||||
|
||||
// Update store (single source of truth)
|
||||
useSessionStore.getState().setCurrentSession(sessionId);
|
||||
|
||||
// Mark this session as being switched to after state update succeeds
|
||||
lastSwitchedSessionRef.current = sessionId;
|
||||
|
||||
if (!skipHistoryLoad) {
|
||||
await loadSessionHistory(sessionId);
|
||||
}
|
||||
// Note: currentLLM will automatically refetch when currentSessionId changes via useQuery
|
||||
} catch (error) {
|
||||
console.error('Error switching session:', error);
|
||||
throw error; // Re-throw so UI can handle the error
|
||||
} finally {
|
||||
// Always reset the switching flag, even if error occurs
|
||||
setIsSwitchingSession(false);
|
||||
}
|
||||
},
|
||||
[currentSessionId, isSwitchingSession, loadSessionHistory, analytics]
|
||||
);
|
||||
|
||||
// Return to welcome state (no active session)
|
||||
const returnToWelcome = useCallback(() => {
|
||||
currentSessionIdRef.current = null;
|
||||
lastSwitchedSessionRef.current = null; // Clear to allow switching to same session again
|
||||
|
||||
// Update store (single source of truth)
|
||||
useSessionStore.getState().returnToWelcome();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ChatContext.Provider
|
||||
value={{
|
||||
messages,
|
||||
sendMessage,
|
||||
reset,
|
||||
switchSession,
|
||||
loadSessionHistory,
|
||||
returnToWelcome,
|
||||
cancel,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ChatContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useChatContext(): ChatContextType {
|
||||
const context = useContext(ChatContext);
|
||||
if (!context) {
|
||||
throw new Error('useChatContext must be used within a ChatProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
54
dexto/packages/webui/components/hooks/useAgentConfig.ts
Normal file
54
dexto/packages/webui/components/hooks/useAgentConfig.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client.js';
|
||||
import { queryKeys } from '@/lib/queryKeys.js';
|
||||
|
||||
// Fetch agent configuration
|
||||
export function useAgentConfig(enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.agent.config,
|
||||
queryFn: async () => {
|
||||
const response = await client.api.agent.config.$get();
|
||||
return await response.json();
|
||||
},
|
||||
enabled,
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
// Validate agent configuration
|
||||
export function useValidateAgent() {
|
||||
return useMutation({
|
||||
mutationFn: async ({ yaml }: { yaml: string }) => {
|
||||
const response = await client.api.agent.validate.$post({
|
||||
json: { yaml },
|
||||
});
|
||||
return await response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Export types inferred from hook return values
|
||||
export type ValidationError = NonNullable<
|
||||
ReturnType<typeof useValidateAgent>['data']
|
||||
>['errors'][number];
|
||||
export type ValidationWarning = NonNullable<
|
||||
ReturnType<typeof useValidateAgent>['data']
|
||||
>['warnings'][number];
|
||||
|
||||
// Save agent configuration
|
||||
export function useSaveAgentConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ yaml }: { yaml: string }) => {
|
||||
const response = await client.api.agent.config.$post({
|
||||
json: { yaml },
|
||||
});
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate agent config to refresh after save
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agent.config });
|
||||
},
|
||||
});
|
||||
}
|
||||
115
dexto/packages/webui/components/hooks/useAgents.ts
Normal file
115
dexto/packages/webui/components/hooks/useAgents.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { client } from '@/lib/client';
|
||||
|
||||
export function useAgents() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.agents.all,
|
||||
queryFn: async () => {
|
||||
const response = await client.api.agents.$get();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch agents: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - agent list rarely changes
|
||||
});
|
||||
}
|
||||
|
||||
export function useAgentPath() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.agents.path,
|
||||
queryFn: async () => {
|
||||
const response = await client.api.agent.path.$get();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch agent path: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - current agent path only changes on explicit switch
|
||||
});
|
||||
}
|
||||
|
||||
export function useSwitchAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
payload: Parameters<typeof client.api.agents.switch.$post>[0]['json']
|
||||
) => {
|
||||
const response = await client.api.agents.switch.$post({ json: payload });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to switch agent: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.path });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstallAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
payload: Parameters<typeof client.api.agents.install.$post>[0]['json']
|
||||
) => {
|
||||
const response = await client.api.agents.install.$post({ json: payload });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to install agent: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUninstallAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
payload: Parameters<typeof client.api.agents.uninstall.$post>[0]['json']
|
||||
) => {
|
||||
const response = await client.api.agents.uninstall.$post({ json: payload });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to uninstall agent: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.path });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
payload: Parameters<typeof client.api.agents.custom.create.$post>[0]['json']
|
||||
) => {
|
||||
const response = await client.api.agents.custom.create.$post({ json: payload });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create agent: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Export inferred types for components to use
|
||||
export type CreateAgentPayload = Parameters<
|
||||
typeof client.api.agents.custom.create.$post
|
||||
>[0]['json'];
|
||||
62
dexto/packages/webui/components/hooks/useApprovals.ts
Normal file
62
dexto/packages/webui/components/hooks/useApprovals.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
type ApprovalPayload = Parameters<(typeof client.api.approvals)[':approvalId']['$post']>[0]['json'];
|
||||
|
||||
export function useSubmitApproval() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
payload: { approvalId: string; sessionId: string } & ApprovalPayload
|
||||
) => {
|
||||
const { approvalId, sessionId: _sessionId, ...body } = payload;
|
||||
const response = await client.api.approvals[':approvalId'].$post({
|
||||
param: { approvalId },
|
||||
json: body,
|
||||
header: {},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to submit approval: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
// Invalidate pending approvals cache when an approval is submitted
|
||||
// Query is keyed by sessionId, not approvalId
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.approvals.pending(variables.sessionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch pending approvals for a session.
|
||||
* Use this to restore approval UI state after page refresh.
|
||||
*
|
||||
* @param sessionId - The session ID to fetch pending approvals for
|
||||
* @param options.enabled - Whether to enable the query (default: true if sessionId provided)
|
||||
*/
|
||||
export function usePendingApprovals(sessionId: string | null, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.approvals.pending(sessionId || ''),
|
||||
queryFn: async () => {
|
||||
if (!sessionId) return { approvals: [] };
|
||||
const response = await client.api.approvals.$get({
|
||||
query: { sessionId },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch pending approvals');
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
enabled: (options?.enabled ?? true) && !!sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
// Export inferred types for consumers
|
||||
export type PendingApproval = NonNullable<
|
||||
ReturnType<typeof usePendingApprovals>['data']
|
||||
>['approvals'][number];
|
||||
405
dexto/packages/webui/components/hooks/useChat.ts
Normal file
405
dexto/packages/webui/components/hooks/useChat.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
37
dexto/packages/webui/components/hooks/useCurrentLLM.ts
Normal file
37
dexto/packages/webui/components/hooks/useCurrentLLM.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { client } from '@/lib/client';
|
||||
|
||||
/**
|
||||
* Hook to fetch the current LLM configuration for a session
|
||||
* Centralized access point for currentLLM data
|
||||
*/
|
||||
export function useCurrentLLM(sessionId: string | null, enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.llm.current(sessionId),
|
||||
queryFn: async () => {
|
||||
const response = await client.api.llm.current.$get({
|
||||
query: sessionId ? { sessionId } : {},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch current LLM config');
|
||||
}
|
||||
const data = await response.json();
|
||||
const cfg = data.config || data;
|
||||
return {
|
||||
provider: cfg.provider,
|
||||
model: cfg.model,
|
||||
displayName: cfg.displayName,
|
||||
baseURL: cfg.baseURL,
|
||||
viaDexto: data.routing?.viaDexto ?? false,
|
||||
};
|
||||
},
|
||||
// Always enabled - API returns default config when no sessionId
|
||||
// This ensures the model name shows on welcome screen
|
||||
enabled,
|
||||
retry: false, // Don't retry on error - UI can still operate
|
||||
});
|
||||
}
|
||||
|
||||
// Export type for components to use
|
||||
export type CurrentLLM = NonNullable<ReturnType<typeof useCurrentLLM>['data']>;
|
||||
23
dexto/packages/webui/components/hooks/useDextoAuth.ts
Normal file
23
dexto/packages/webui/components/hooks/useDextoAuth.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
/**
|
||||
* Hook to fetch Dexto authentication status.
|
||||
* Returns whether dexto auth is enabled, user is authenticated, and can use dexto provider.
|
||||
*/
|
||||
export function useDextoAuth(enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.dextoAuth.status,
|
||||
queryFn: async () => {
|
||||
const res = await client.api['dexto-auth'].status.$get();
|
||||
if (!res.ok) throw new Error('Failed to fetch dexto auth status');
|
||||
return await res.json();
|
||||
},
|
||||
enabled,
|
||||
staleTime: 30 * 1000, // 30 seconds - auth status may change
|
||||
});
|
||||
}
|
||||
|
||||
// Export types using the standard inference pattern
|
||||
export type DextoAuthStatus = NonNullable<ReturnType<typeof useDextoAuth>['data']>;
|
||||
25
dexto/packages/webui/components/hooks/useDiscovery.ts
Normal file
25
dexto/packages/webui/components/hooks/useDiscovery.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
/**
|
||||
* Hook to fetch available providers and capabilities.
|
||||
* Returns blob storage providers, compression strategies, custom tool providers, and internal tools.
|
||||
*/
|
||||
export function useDiscovery(enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.discovery.all,
|
||||
queryFn: async () => {
|
||||
const res = await client.api.discovery.$get();
|
||||
if (!res.ok) throw new Error('Failed to fetch discovery data');
|
||||
return await res.json();
|
||||
},
|
||||
enabled,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - providers don't change often
|
||||
});
|
||||
}
|
||||
|
||||
// Export types using the standard inference pattern
|
||||
export type DiscoveryResponse = NonNullable<ReturnType<typeof useDiscovery>['data']>;
|
||||
export type DiscoveredProvider = DiscoveryResponse['blob'][number];
|
||||
export type InternalToolInfo = DiscoveryResponse['internalTools'][number];
|
||||
29
dexto/packages/webui/components/hooks/useFontsReady.ts
Normal file
29
dexto/packages/webui/components/hooks/useFontsReady.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// Returns true when the browser reports that page fonts are loaded.
|
||||
// This lets us defer first-measure actions (like autosize) until
|
||||
// typographic metrics are stable to avoid initial reflow.
|
||||
export function useFontsReady(): boolean {
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
// If Font Loading API is unavailable, assume ready to avoid blocking.
|
||||
const anyDoc = document as any;
|
||||
if (!anyDoc.fonts || !anyDoc.fonts.ready) {
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
anyDoc.fonts.ready.then(() => {
|
||||
if (!cancelled) setReady(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return ready;
|
||||
}
|
||||
28
dexto/packages/webui/components/hooks/useGreeting.ts
Normal file
28
dexto/packages/webui/components/hooks/useGreeting.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client.js';
|
||||
import { queryKeys } from '@/lib/queryKeys.js';
|
||||
|
||||
async function fetchGreeting(sessionId?: string | null): Promise<string | null> {
|
||||
const data = await client.api.greeting.$get({
|
||||
query: sessionId ? { sessionId } : {},
|
||||
});
|
||||
if (!data.ok) {
|
||||
throw new Error(`Failed to fetch greeting: ${data.status}`);
|
||||
}
|
||||
const json = await data.json();
|
||||
return json.greeting ?? null;
|
||||
}
|
||||
|
||||
export function useGreeting(sessionId?: string | null) {
|
||||
const {
|
||||
data: greeting = null,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.greeting(sessionId),
|
||||
queryFn: () => fetchGreeting(sessionId),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - greeting is static per agent
|
||||
});
|
||||
|
||||
return { greeting, isLoading, error: error?.message ?? null };
|
||||
}
|
||||
187
dexto/packages/webui/components/hooks/useInputHistory.ts
Normal file
187
dexto/packages/webui/components/hooks/useInputHistory.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { isTextPart } from '../../types';
|
||||
|
||||
const MAX_HISTORY_SIZE = 100;
|
||||
|
||||
/**
|
||||
* Hook to fetch user messages from session history
|
||||
*/
|
||||
function useSessionUserMessages(sessionId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.sessions.history(sessionId ?? ''),
|
||||
queryFn: async () => {
|
||||
if (!sessionId) return [];
|
||||
const response = await client.api.sessions[':sessionId'].history.$get({
|
||||
param: { sessionId },
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
|
||||
const data = await response.json();
|
||||
const historyMessages = data.history || [];
|
||||
|
||||
// Extract text content from user messages
|
||||
const userTexts: string[] = [];
|
||||
for (const msg of historyMessages) {
|
||||
if (msg.role !== 'user') continue;
|
||||
if (!msg.content || !Array.isArray(msg.content)) continue;
|
||||
|
||||
const textParts = msg.content
|
||||
.filter(isTextPart)
|
||||
.map((part) => part.text.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
|
||||
if (textParts.length > 0) {
|
||||
userTexts.push(textParts.join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate consecutive entries
|
||||
const deduplicated: string[] = [];
|
||||
for (const text of userTexts) {
|
||||
if (deduplicated.length === 0 || deduplicated[deduplicated.length - 1] !== text) {
|
||||
deduplicated.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
return deduplicated.slice(-MAX_HISTORY_SIZE);
|
||||
},
|
||||
enabled: !!sessionId,
|
||||
staleTime: 30000, // Consider fresh for 30s
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing input history with shell-style navigation.
|
||||
*
|
||||
* - Up arrow: Navigate to older entries
|
||||
* - Down arrow: Navigate to newer entries
|
||||
* - History cursor resets when user types new input
|
||||
* - Loads previous user messages from session history via TanStack Query
|
||||
*
|
||||
*/
|
||||
export function useInputHistory(sessionId: string | null) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch historical user messages from session
|
||||
const { data: history = [] } = useSessionUserMessages(sessionId);
|
||||
|
||||
// Current position in history (-1 means not browsing, 0 = oldest, length-1 = newest)
|
||||
const [cursor, setCursor] = useState<number>(-1);
|
||||
// Track the text that was in input before browsing started
|
||||
const savedInputRef = useRef<string>('');
|
||||
// Track last recalled text to prevent hijacking normal editing
|
||||
const lastRecalledRef = useRef<string | null>(null);
|
||||
|
||||
// Reset cursor when session changes
|
||||
useEffect(() => {
|
||||
setCursor(-1);
|
||||
lastRecalledRef.current = null;
|
||||
savedInputRef.current = '';
|
||||
}, [sessionId]);
|
||||
|
||||
/**
|
||||
* Invalidate history cache after sending a message.
|
||||
* Call this after successfully sending to refresh the history.
|
||||
*/
|
||||
const invalidateHistory = useCallback(() => {
|
||||
if (sessionId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.sessions.history(sessionId),
|
||||
});
|
||||
}
|
||||
}, [queryClient, sessionId]);
|
||||
|
||||
/**
|
||||
* Check if we should handle navigation (up/down) vs normal cursor movement.
|
||||
* Only handle navigation when:
|
||||
* 1. Input is empty, OR
|
||||
* 2. Cursor is at position 0 AND text matches last recalled history
|
||||
*/
|
||||
const shouldHandleNavigation = useCallback(
|
||||
(currentText: string, cursorPosition: number): boolean => {
|
||||
if (!currentText) return true;
|
||||
if (cursorPosition === 0 && lastRecalledRef.current === currentText) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* Navigate up (older entries)
|
||||
* Returns the text to display, or null if at end of history
|
||||
*/
|
||||
const navigateUp = useCallback(
|
||||
(currentText: string): string | null => {
|
||||
if (history.length === 0) return null;
|
||||
|
||||
// If not currently browsing, save current input and start from newest
|
||||
if (cursor === -1) {
|
||||
savedInputRef.current = currentText;
|
||||
const idx = history.length - 1;
|
||||
setCursor(idx);
|
||||
const text = history[idx];
|
||||
lastRecalledRef.current = text ?? null;
|
||||
return text ?? null;
|
||||
}
|
||||
|
||||
// Already at oldest entry
|
||||
if (cursor === 0) return null;
|
||||
|
||||
// Move to older entry
|
||||
const newCursor = cursor - 1;
|
||||
setCursor(newCursor);
|
||||
const text = history[newCursor];
|
||||
lastRecalledRef.current = text ?? null;
|
||||
return text ?? null;
|
||||
},
|
||||
[history, cursor]
|
||||
);
|
||||
|
||||
/**
|
||||
* Navigate down (newer entries)
|
||||
* Returns the text to display, or null if back to current input
|
||||
*/
|
||||
const navigateDown = useCallback((): string | null => {
|
||||
if (cursor === -1) return null;
|
||||
|
||||
// At newest entry - return to saved input
|
||||
if (cursor === history.length - 1) {
|
||||
setCursor(-1);
|
||||
lastRecalledRef.current = null;
|
||||
return savedInputRef.current;
|
||||
}
|
||||
|
||||
// Move to newer entry
|
||||
const newCursor = cursor + 1;
|
||||
setCursor(newCursor);
|
||||
const text = history[newCursor];
|
||||
lastRecalledRef.current = text ?? null;
|
||||
return text ?? null;
|
||||
}, [history, cursor]);
|
||||
|
||||
/**
|
||||
* Reset history browsing (call when user types)
|
||||
*/
|
||||
const resetCursor = useCallback(() => {
|
||||
if (cursor !== -1) {
|
||||
setCursor(-1);
|
||||
lastRecalledRef.current = null;
|
||||
}
|
||||
}, [cursor]);
|
||||
|
||||
return {
|
||||
history,
|
||||
cursor,
|
||||
invalidateHistory,
|
||||
navigateUp,
|
||||
navigateDown,
|
||||
resetCursor,
|
||||
shouldHandleNavigation,
|
||||
isBrowsing: cursor !== -1,
|
||||
};
|
||||
}
|
||||
165
dexto/packages/webui/components/hooks/useLLM.ts
Normal file
165
dexto/packages/webui/components/hooks/useLLM.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { client } from '@/lib/client';
|
||||
|
||||
export function useLLMCatalog(options?: { enabled?: boolean; mode?: 'grouped' | 'flat' }) {
|
||||
const mode = options?.mode ?? 'grouped';
|
||||
return useQuery({
|
||||
queryKey: [...queryKeys.llm.catalog, mode],
|
||||
queryFn: async () => {
|
||||
const response = await client.api.llm.catalog.$get({ query: { mode } });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch LLM catalog: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - catalog rarely changes
|
||||
});
|
||||
}
|
||||
|
||||
export function useSwitchLLM() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: SwitchLLMPayload) => {
|
||||
const response = await client.api.llm.switch.$post({ json: payload });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to switch LLM: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate catalog and all current LLM queries to refresh all views
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.llm.catalog });
|
||||
queryClient.invalidateQueries({ queryKey: ['llm', 'current'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useProviderApiKey(provider: LLMProvider | null, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: [...queryKeys.llm.catalog, 'key', provider],
|
||||
queryFn: async () => {
|
||||
if (!provider) return null;
|
||||
const response = await client.api.llm.key[':provider'].$get({
|
||||
param: { provider },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch API key: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
enabled: (options?.enabled ?? true) && !!provider,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveApiKey() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: SaveApiKeyPayload) => {
|
||||
const response = await client.api.llm.key.$post({ json: payload });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to save API key: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.llm.catalog });
|
||||
// Also invalidate the specific provider key query
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...queryKeys.llm.catalog, 'key', variables.provider],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Custom models hooks
|
||||
export function useCustomModels(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.llm.customModels,
|
||||
queryFn: async () => {
|
||||
const response = await client.api.llm['custom-models'].$get();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch custom models: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.models;
|
||||
},
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCustomModel() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: CustomModelPayload) => {
|
||||
const response = await client.api.llm['custom-models'].$post({ json: payload });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create custom model: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.llm.customModels });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCustomModel() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
// URL-encode the name to handle OpenRouter model IDs with slashes (e.g., anthropic/claude-3.5-sonnet)
|
||||
const encodedName = encodeURIComponent(name);
|
||||
const response = await client.api.llm['custom-models'][':name'].$delete({
|
||||
param: { name: encodedName },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete custom model: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.llm.customModels });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Model capabilities hook - resolves gateway providers to underlying model capabilities
|
||||
export function useModelCapabilities(
|
||||
provider: LLMProvider | null | undefined,
|
||||
model: string | null | undefined,
|
||||
options?: { enabled?: boolean }
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [...queryKeys.llm.catalog, 'capabilities', provider, model],
|
||||
queryFn: async () => {
|
||||
if (!provider || !model) return null;
|
||||
const response = await client.api.llm.capabilities.$get({
|
||||
query: { provider, model },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch model capabilities: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
enabled: (options?.enabled ?? true) && !!provider && !!model,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - capabilities rarely change
|
||||
});
|
||||
}
|
||||
|
||||
// Export inferred types for components to use
|
||||
export type SaveApiKeyPayload = Parameters<typeof client.api.llm.key.$post>[0]['json'];
|
||||
export type LLMProvider = SaveApiKeyPayload['provider'];
|
||||
export type SwitchLLMPayload = Parameters<typeof client.api.llm.switch.$post>[0]['json'];
|
||||
|
||||
// Helper to extract the custom-models endpoint (Prettier can't parse hyphenated bracket notation in Parameters<>)
|
||||
type CustomModelsEndpoint = (typeof client.api.llm)['custom-models'];
|
||||
export type CustomModelPayload = Parameters<CustomModelsEndpoint['$post']>[0]['json'];
|
||||
export type CustomModel = NonNullable<ReturnType<typeof useCustomModels>['data']>[number];
|
||||
56
dexto/packages/webui/components/hooks/useMemories.ts
Normal file
56
dexto/packages/webui/components/hooks/useMemories.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { client } from '@/lib/client';
|
||||
|
||||
export function useMemories(enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.memories.all,
|
||||
queryFn: async () => {
|
||||
const response = await client.api.memory.$get({ query: {} });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch memories: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.memories;
|
||||
},
|
||||
enabled,
|
||||
staleTime: 30 * 1000, // 30 seconds - memories can be added during chat
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMemory() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ memoryId }: { memoryId: string }) => {
|
||||
const response = await client.api.memory[':id'].$delete({ param: { id: memoryId } });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete memory: ${response.status}`);
|
||||
}
|
||||
return memoryId;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.memories.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateMemory() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: Parameters<typeof client.api.memory.$post>[0]['json']) => {
|
||||
const response = await client.api.memory.$post({ json: payload });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create memory: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.memories.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Export inferred types for components to use
|
||||
export type Memory = NonNullable<ReturnType<typeof useMemories>['data']>[number];
|
||||
114
dexto/packages/webui/components/hooks/useModels.ts
Normal file
114
dexto/packages/webui/components/hooks/useModels.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Hooks for local GGUF and Ollama model management.
|
||||
*
|
||||
* These hooks expose model discovery that was previously only available in CLI.
|
||||
* Used by the model picker to display installed local models and Ollama models.
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { client } from '@/lib/client';
|
||||
|
||||
/**
|
||||
* Fetch installed local GGUF models from state.json.
|
||||
* These are models downloaded via CLI or manually registered.
|
||||
*/
|
||||
export function useLocalModels(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.models.local,
|
||||
queryFn: async () => {
|
||||
const response = await client.api.models.local.$get();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch local models: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: 30 * 1000, // 30 seconds - models don't change often
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available Ollama models from the Ollama server.
|
||||
* Returns empty list with available=false if Ollama is not running.
|
||||
*/
|
||||
export function useOllamaModels(options?: { enabled?: boolean; baseURL?: string }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.models.ollama(options?.baseURL),
|
||||
queryFn: async () => {
|
||||
const response = await client.api.models.ollama.$get({
|
||||
query: options?.baseURL ? { baseURL: options.baseURL } : {},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch Ollama models: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
retry: false, // Don't retry if Ollama not running
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a local GGUF file path.
|
||||
* Used by the custom model form to validate file paths before saving.
|
||||
*/
|
||||
export function useValidateLocalFile() {
|
||||
return useMutation({
|
||||
mutationFn: async (filePath: string) => {
|
||||
const response = await client.api.models.local.validate.$post({
|
||||
json: { filePath },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to validate file: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an installed local model.
|
||||
* Removes from state.json and optionally deletes the GGUF file from disk.
|
||||
*/
|
||||
export function useDeleteInstalledModel() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
modelId,
|
||||
deleteFile = true,
|
||||
}: {
|
||||
modelId: string;
|
||||
deleteFile?: boolean;
|
||||
}) => {
|
||||
const response = await client.api.models.local[':modelId'].$delete({
|
||||
param: { modelId },
|
||||
json: { deleteFile },
|
||||
});
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Failed to delete model: ${response.status}`;
|
||||
try {
|
||||
const data = await response.json();
|
||||
if (data.error) errorMessage = data.error;
|
||||
} catch {
|
||||
// Response body not JSON-parseable (e.g., network error, proxy error), use default message
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate local models cache to refresh the list
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.models.local });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Export inferred types for components to use
|
||||
export type LocalModel = NonNullable<ReturnType<typeof useLocalModels>['data']>['models'][number];
|
||||
export type OllamaModel = NonNullable<ReturnType<typeof useOllamaModels>['data']>['models'][number];
|
||||
export type ValidateLocalFileResult = Awaited<
|
||||
ReturnType<ReturnType<typeof useValidateLocalFile>['mutateAsync']>
|
||||
>;
|
||||
27
dexto/packages/webui/components/hooks/useOpenRouter.ts
Normal file
27
dexto/packages/webui/components/hooks/useOpenRouter.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client';
|
||||
|
||||
/**
|
||||
* Validate an OpenRouter model ID against the registry.
|
||||
* Returns validation result with status and optional error.
|
||||
*/
|
||||
export function useValidateOpenRouterModel() {
|
||||
return useMutation({
|
||||
mutationFn: async (modelId: string) => {
|
||||
// URL-encode the model ID to handle slashes (e.g., anthropic/claude-3.5-sonnet)
|
||||
const encodedModelId = encodeURIComponent(modelId);
|
||||
const response = await client.api.openrouter.validate[':modelId'].$get({
|
||||
param: { modelId: encodedModelId },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to validate model: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Export inferred types
|
||||
export type ValidateOpenRouterModelResult = Awaited<
|
||||
ReturnType<ReturnType<typeof useValidateOpenRouterModel>['mutateAsync']>
|
||||
>;
|
||||
69
dexto/packages/webui/components/hooks/usePrompts.ts
Normal file
69
dexto/packages/webui/components/hooks/usePrompts.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import { client } from '@/lib/client.js';
|
||||
|
||||
/**
|
||||
* Hook for fetching prompts with TanStack Query caching
|
||||
*
|
||||
* Replaces the old promptCache.ts in-memory cache with proper
|
||||
* persistent caching that survives page refreshes.
|
||||
*/
|
||||
export function usePrompts(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.prompts.all,
|
||||
queryFn: async () => {
|
||||
const response = await client.api.prompts.$get();
|
||||
const data = await response.json();
|
||||
return data.prompts;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
|
||||
gcTime: 30 * 60 * 1000, // Keep in cache for 30 minutes
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePrompt() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
payload: Parameters<typeof client.api.prompts.custom.$post>[0]['json']
|
||||
) => {
|
||||
const response = await client.api.prompts.custom.$post({ json: payload });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create prompt: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type ResolvePromptParams = Parameters<(typeof client.api.prompts)[':name']['resolve']['$get']>[0];
|
||||
|
||||
export function useResolvePrompt() {
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
payload: {
|
||||
name: string;
|
||||
} & ResolvePromptParams['query']
|
||||
) => {
|
||||
const { name, ...query } = payload;
|
||||
const response = await client.api.prompts[':name'].resolve.$get({
|
||||
param: { name: encodeURIComponent(name) },
|
||||
query,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to resolve prompt: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Export inferred types for components to use
|
||||
export type Prompt = NonNullable<ReturnType<typeof usePrompts>['data']>[number];
|
||||
145
dexto/packages/webui/components/hooks/useQueue.ts
Normal file
145
dexto/packages/webui/components/hooks/useQueue.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
/**
|
||||
* Hook to fetch queued messages for a session
|
||||
*/
|
||||
export function useQueuedMessages(sessionId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.queue.list(sessionId ?? ''),
|
||||
queryFn: async () => {
|
||||
if (!sessionId) return { messages: [], count: 0 };
|
||||
const response = await client.api.queue[':sessionId'].$get({
|
||||
param: { sessionId },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch queued messages');
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
enabled: !!sessionId,
|
||||
// Refetch frequently while processing to show queue updates
|
||||
refetchInterval: (query) => ((query.state.data?.count ?? 0) > 0 ? 2000 : false),
|
||||
});
|
||||
}
|
||||
|
||||
// Export type for queued message
|
||||
export type QueuedMessage = NonNullable<
|
||||
ReturnType<typeof useQueuedMessages>['data']
|
||||
>['messages'][number];
|
||||
|
||||
/**
|
||||
* Hook to queue a new message
|
||||
*/
|
||||
export function useQueueMessage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
sessionId,
|
||||
message,
|
||||
imageData,
|
||||
fileData,
|
||||
}: {
|
||||
sessionId: string;
|
||||
message?: string;
|
||||
imageData?: { image: string; mimeType: string };
|
||||
fileData?: { data: string; mimeType: string; filename?: string };
|
||||
}) => {
|
||||
// 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 (message) {
|
||||
contentParts.push({ type: 'text', text: message });
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await client.api.queue[':sessionId'].$post({
|
||||
param: { sessionId },
|
||||
json: {
|
||||
content:
|
||||
contentParts.length === 1 && contentParts[0]?.type === 'text'
|
||||
? message! // Simple text-only case: send as string
|
||||
: contentParts, // Multimodal: send as array
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to queue message');
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.queue.list(variables.sessionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to remove a single queued message
|
||||
*/
|
||||
export function useRemoveQueuedMessage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ sessionId, messageId }: { sessionId: string; messageId: string }) => {
|
||||
const response = await client.api.queue[':sessionId'][':messageId'].$delete({
|
||||
param: { sessionId, messageId },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to remove queued message');
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.queue.list(variables.sessionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to clear all queued messages for a session
|
||||
*/
|
||||
export function useClearQueue() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (sessionId: string) => {
|
||||
const response = await client.api.queue[':sessionId'].$delete({
|
||||
param: { sessionId },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to clear queue');
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: (_, sessionId) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.queue.list(sessionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
203
dexto/packages/webui/components/hooks/useResourceContent.ts
Normal file
203
dexto/packages/webui/components/hooks/useResourceContent.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client';
|
||||
|
||||
type NormalizedResourceItem =
|
||||
| {
|
||||
kind: 'text';
|
||||
text: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'image';
|
||||
src: string;
|
||||
mimeType: string;
|
||||
alt?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'audio';
|
||||
src: string;
|
||||
mimeType: string;
|
||||
filename?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'video';
|
||||
src: string;
|
||||
mimeType: string;
|
||||
filename?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'file';
|
||||
src?: string;
|
||||
mimeType?: string;
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
export interface NormalizedResource {
|
||||
uri: string;
|
||||
name?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
items: NormalizedResourceItem[];
|
||||
}
|
||||
|
||||
export interface ResourceState {
|
||||
status: 'loading' | 'loaded' | 'error';
|
||||
data?: NormalizedResource;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type ResourceStateMap = Record<string, ResourceState>;
|
||||
|
||||
function buildDataUrl(base64: string, mimeType: string): string {
|
||||
return `data:${mimeType};base64,${base64}`;
|
||||
}
|
||||
|
||||
function normalizeResource(uri: string, payload: any): NormalizedResource {
|
||||
const contents = Array.isArray(payload?.contents) ? payload.contents : [];
|
||||
const meta = (payload?._meta ?? {}) as Record<string, unknown>;
|
||||
const name =
|
||||
(typeof meta.originalName === 'string' && meta.originalName.trim().length > 0
|
||||
? meta.originalName
|
||||
: undefined) || uri;
|
||||
|
||||
const items: NormalizedResourceItem[] = [];
|
||||
|
||||
for (const item of contents) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
|
||||
if (typeof (item as { text?: unknown }).text === 'string') {
|
||||
items.push({
|
||||
kind: 'text',
|
||||
text: (item as { text: string }).text,
|
||||
mimeType: typeof item.mimeType === 'string' ? item.mimeType : undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const blobData = typeof item.blob === 'string' ? item.blob : undefined;
|
||||
const rawData = typeof item.data === 'string' ? item.data : undefined;
|
||||
const mimeType = typeof item.mimeType === 'string' ? item.mimeType : undefined;
|
||||
const filename = typeof item.filename === 'string' ? item.filename : undefined;
|
||||
|
||||
if ((blobData || rawData) && mimeType) {
|
||||
const base64 = blobData ?? rawData!;
|
||||
const src = buildDataUrl(base64, mimeType);
|
||||
if (mimeType.startsWith('image/')) {
|
||||
items.push({
|
||||
kind: 'image',
|
||||
src,
|
||||
mimeType,
|
||||
alt: filename || name,
|
||||
});
|
||||
} else if (mimeType.startsWith('audio/')) {
|
||||
items.push({
|
||||
kind: 'audio',
|
||||
src,
|
||||
mimeType,
|
||||
filename: filename || name,
|
||||
});
|
||||
} else if (mimeType.startsWith('video/')) {
|
||||
items.push({
|
||||
kind: 'video',
|
||||
src,
|
||||
mimeType,
|
||||
filename: filename || name,
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
kind: 'file',
|
||||
src,
|
||||
mimeType,
|
||||
filename: filename || name,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mimeType && mimeType.startsWith('text/') && typeof item.value === 'string') {
|
||||
items.push({
|
||||
kind: 'text',
|
||||
text: item.value,
|
||||
mimeType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uri,
|
||||
name,
|
||||
meta,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchResourceContent(uri: string): Promise<NormalizedResource> {
|
||||
const response = await client.api.resources[':resourceId'].content.$get({
|
||||
param: { resourceId: encodeURIComponent(uri) },
|
||||
});
|
||||
const body = await response.json();
|
||||
const contentPayload = body?.content;
|
||||
if (!contentPayload) {
|
||||
throw new Error('No content returned for resource');
|
||||
}
|
||||
return normalizeResource(uri, contentPayload);
|
||||
}
|
||||
|
||||
export function useResourceContent(resourceUris: string[]): ResourceStateMap {
|
||||
// Serialize array for stable dependency comparison.
|
||||
// Arrays are compared by reference in React, so ['a','b'] !== ['a','b'] even though
|
||||
// values are identical. Serializing to 'a|b' allows value-based comparison to avoid
|
||||
// unnecessary re-computation when parent passes new array reference with same contents.
|
||||
const serializedUris = resourceUris.join('|');
|
||||
|
||||
const normalizedUris = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
for (const uri of resourceUris) {
|
||||
if (!uri || typeof uri !== 'string') continue;
|
||||
const trimmed = uri.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
seen.add(trimmed);
|
||||
ordered.push(trimmed);
|
||||
}
|
||||
return ordered;
|
||||
// We use resourceUris inside but only depend on serializedUris. This is safe because
|
||||
// serializedUris is derived from resourceUris - when the string changes, the array
|
||||
// values changed too. This is an intentional optimization to prevent re-runs when
|
||||
// array reference changes but values remain the same.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [serializedUris]);
|
||||
|
||||
const queries = useQueries({
|
||||
queries: normalizedUris.map((uri) => ({
|
||||
queryKey: ['resourceContent', uri],
|
||||
queryFn: () => fetchResourceContent(uri),
|
||||
enabled: !!uri,
|
||||
retry: false,
|
||||
})),
|
||||
});
|
||||
|
||||
const resources: ResourceStateMap = useMemo(() => {
|
||||
const result: ResourceStateMap = {};
|
||||
queries.forEach((query, index) => {
|
||||
const uri = normalizedUris[index];
|
||||
if (!uri) return;
|
||||
|
||||
if (query.isLoading) {
|
||||
result[uri] = { status: 'loading' };
|
||||
} else if (query.error) {
|
||||
result[uri] = {
|
||||
status: 'error',
|
||||
error: query.error instanceof Error ? query.error.message : String(query.error),
|
||||
};
|
||||
} else if (query.data) {
|
||||
result[uri] = { status: 'loaded', data: query.data };
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}, [queries, normalizedUris]);
|
||||
|
||||
return resources;
|
||||
}
|
||||
|
||||
export type { NormalizedResourceItem };
|
||||
34
dexto/packages/webui/components/hooks/useResources.ts
Normal file
34
dexto/packages/webui/components/hooks/useResources.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client.js';
|
||||
import { queryKeys } from '@/lib/queryKeys.js';
|
||||
|
||||
async function fetchResources() {
|
||||
const response = await client.api.resources.$get();
|
||||
const data = await response.json();
|
||||
if (!data.ok || !Array.isArray(data.resources)) {
|
||||
throw new Error('Invalid response shape');
|
||||
}
|
||||
return data.resources;
|
||||
}
|
||||
|
||||
export function useResources() {
|
||||
const {
|
||||
data: resources = [],
|
||||
isLoading: loading,
|
||||
error,
|
||||
refetch: refresh,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.resources.all,
|
||||
queryFn: fetchResources,
|
||||
staleTime: 60 * 1000, // 1 minute - resources can change when servers connect/disconnect
|
||||
});
|
||||
|
||||
return {
|
||||
resources,
|
||||
loading,
|
||||
error: error?.message ?? null,
|
||||
refresh: async () => {
|
||||
await refresh();
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
50
dexto/packages/webui/components/hooks/useSearch.ts
Normal file
50
dexto/packages/webui/components/hooks/useSearch.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client.js';
|
||||
import { queryKeys } from '@/lib/queryKeys.js';
|
||||
|
||||
// Search messages
|
||||
export function useSearchMessages(
|
||||
query: string,
|
||||
sessionId?: string,
|
||||
limit: number = 50,
|
||||
enabled: boolean = true
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.search.messages(query, sessionId, limit),
|
||||
queryFn: async () => {
|
||||
const response = await client.api.search.messages.$get({
|
||||
query: {
|
||||
q: query,
|
||||
limit: limit,
|
||||
...(sessionId && { sessionId }),
|
||||
},
|
||||
});
|
||||
return await response.json();
|
||||
},
|
||||
enabled: enabled && query.trim().length > 0,
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
// Search sessions
|
||||
export function useSearchSessions(query: string, enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.search.sessions(query),
|
||||
queryFn: async () => {
|
||||
const response = await client.api.search.sessions.$get({
|
||||
query: { q: query },
|
||||
});
|
||||
return await response.json();
|
||||
},
|
||||
enabled: enabled && query.trim().length > 0,
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
// Export types inferred from hook return values
|
||||
export type SearchResult = NonNullable<
|
||||
ReturnType<typeof useSearchMessages>['data']
|
||||
>['results'][number];
|
||||
export type SessionSearchResult = NonNullable<
|
||||
ReturnType<typeof useSearchSessions>['data']
|
||||
>['results'][number];
|
||||
74
dexto/packages/webui/components/hooks/useServerRegistry.ts
Normal file
74
dexto/packages/webui/components/hooks/useServerRegistry.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { serverRegistry } from '@/lib/serverRegistry';
|
||||
import type { ServerRegistryEntry, ServerRegistryFilter } from '@dexto/registry';
|
||||
import { queryKeys } from '@/lib/queryKeys.js';
|
||||
|
||||
interface UseServerRegistryOptions {
|
||||
autoLoad?: boolean;
|
||||
initialFilter?: ServerRegistryFilter;
|
||||
}
|
||||
|
||||
export function useServerRegistry(options: UseServerRegistryOptions = {}) {
|
||||
const { autoLoad = true, initialFilter } = options;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [filter, setFilter] = useState<ServerRegistryFilter>(initialFilter || {});
|
||||
|
||||
const {
|
||||
data: entries = [],
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.serverRegistry(filter),
|
||||
queryFn: () => serverRegistry.getEntries(filter),
|
||||
enabled: autoLoad,
|
||||
});
|
||||
|
||||
const markAsInstalledMutation = useMutation({
|
||||
mutationFn: async (entryId: string) => {
|
||||
await serverRegistry.setInstalled(entryId, true);
|
||||
return entryId;
|
||||
},
|
||||
onSuccess: (entryId) => {
|
||||
// Optimistically update the cache
|
||||
queryClient.setQueryData<ServerRegistryEntry[]>(
|
||||
queryKeys.serverRegistry(filter),
|
||||
(old) =>
|
||||
old?.map((entry) =>
|
||||
entry.id === entryId ? { ...entry, isInstalled: true } : entry
|
||||
) ?? []
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const updateFilter = (newFilter: ServerRegistryFilter) => {
|
||||
setFilter(newFilter);
|
||||
};
|
||||
|
||||
const loadEntries = async (newFilter?: ServerRegistryFilter) => {
|
||||
if (newFilter) {
|
||||
setFilter(newFilter);
|
||||
} else {
|
||||
// Trigger a refetch with current filter
|
||||
await queryClient.refetchQueries({ queryKey: queryKeys.serverRegistry(filter) });
|
||||
}
|
||||
};
|
||||
|
||||
const markAsInstalled = async (entryId: string) => {
|
||||
await markAsInstalledMutation.mutateAsync(entryId);
|
||||
};
|
||||
|
||||
return {
|
||||
entries,
|
||||
isLoading,
|
||||
error: error?.message ?? null,
|
||||
filter,
|
||||
loadEntries,
|
||||
updateFilter,
|
||||
markAsInstalled,
|
||||
clearError: () => {
|
||||
// Errors are automatically cleared when query succeeds
|
||||
},
|
||||
};
|
||||
}
|
||||
109
dexto/packages/webui/components/hooks/useServers.ts
Normal file
109
dexto/packages/webui/components/hooks/useServers.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
export function useServers(enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.servers.all,
|
||||
queryFn: async () => {
|
||||
const res = await client.api.mcp.servers.$get();
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch servers');
|
||||
}
|
||||
const data = await res.json();
|
||||
// Type is inferred from Hono client response schema
|
||||
return data.servers;
|
||||
},
|
||||
enabled,
|
||||
staleTime: 30 * 1000, // 30 seconds - server status can change
|
||||
});
|
||||
}
|
||||
|
||||
export function useServerTools(serverId: string | null, enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.servers.tools(serverId || ''),
|
||||
queryFn: async () => {
|
||||
if (!serverId) return [];
|
||||
const res = await client.api.mcp.servers[':serverId'].tools.$get({
|
||||
param: { serverId },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch tools');
|
||||
}
|
||||
const data = await res.json();
|
||||
// Type is inferred from Hono client response schema
|
||||
return data.tools;
|
||||
},
|
||||
enabled: enabled && !!serverId,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes - tools don't change once server is connected
|
||||
});
|
||||
}
|
||||
|
||||
// Add new MCP server
|
||||
export function useAddServer() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: Parameters<typeof client.api.mcp.servers.$post>[0]['json']) => {
|
||||
const res = await client.api.mcp.servers.$post({ json: payload });
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
throw new Error(error || 'Failed to add server');
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.resources.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Delete MCP server
|
||||
export function useDeleteServer() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (serverId: string) => {
|
||||
const res = await client.api.mcp.servers[':serverId'].$delete({
|
||||
param: { serverId },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to delete server');
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.resources.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Restart MCP server
|
||||
export function useRestartServer() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (serverId: string) => {
|
||||
const res = await client.api.mcp.servers[':serverId'].restart.$post({
|
||||
param: { serverId },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to restart server');
|
||||
}
|
||||
return serverId;
|
||||
},
|
||||
onSuccess: (serverId) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.servers.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.resources.all });
|
||||
// Invalidate tools for this server as they may have changed after restart
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.servers.tools(serverId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Export types inferred from hook return values
|
||||
export type McpServer = NonNullable<ReturnType<typeof useServers>['data']>[number];
|
||||
export type McpTool = NonNullable<ReturnType<typeof useServerTools>['data']>[number];
|
||||
85
dexto/packages/webui/components/hooks/useSessions.ts
Normal file
85
dexto/packages/webui/components/hooks/useSessions.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client.js';
|
||||
import { queryKeys } from '@/lib/queryKeys.js';
|
||||
|
||||
export function useSessions(enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.sessions.all,
|
||||
queryFn: async () => {
|
||||
const response = await client.api.sessions.$get();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch sessions: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.sessions;
|
||||
},
|
||||
enabled,
|
||||
staleTime: 30 * 1000, // 30 seconds - sessions can be created frequently
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new session
|
||||
export function useCreateSession() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ sessionId }: { sessionId?: string }) => {
|
||||
const response = await client.api.sessions.$post({
|
||||
json: { sessionId: sessionId?.trim() || undefined },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create session: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.session;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Delete a session
|
||||
export function useDeleteSession() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ sessionId }: { sessionId: string }) => {
|
||||
const response = await client.api.sessions[':sessionId'].$delete({
|
||||
param: { sessionId },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete session: ${response.status}`);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate sessions list to refresh after deletion
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Rename a session (update title)
|
||||
export function useRenameSession() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ sessionId, title }: { sessionId: string; title: string }) => {
|
||||
const response = await client.api.sessions[':sessionId'].$patch({
|
||||
param: { sessionId },
|
||||
json: { title },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to rename session');
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.session;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sessions.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Export inferred types for components to use
|
||||
export type Session = NonNullable<ReturnType<typeof useSessions>['data']>[number];
|
||||
33
dexto/packages/webui/components/hooks/useTheme.ts
Normal file
33
dexto/packages/webui/components/hooks/useTheme.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useTheme() {
|
||||
// Initialize from SSR-provided class on <html> to avoid flicker
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
}
|
||||
// Match SSR default from layout (dark)
|
||||
return 'dark';
|
||||
});
|
||||
|
||||
// Sync DOM class, localStorage and cookie when theme changes
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
try {
|
||||
localStorage.setItem('theme', theme);
|
||||
const isSecure =
|
||||
typeof window !== 'undefined' && window.location?.protocol === 'https:';
|
||||
document.cookie = `theme=${encodeURIComponent(theme)}; path=/; max-age=31536000; SameSite=Lax${isSecure ? '; Secure' : ''}`;
|
||||
} catch {
|
||||
// Ignore storage errors in restrictive environments
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = (checked: boolean) => {
|
||||
setTheme(checked ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
// Keep API shape backward-compatible
|
||||
return { theme, toggleTheme, hasMounted: true } as const;
|
||||
}
|
||||
19
dexto/packages/webui/components/hooks/useTools.ts
Normal file
19
dexto/packages/webui/components/hooks/useTools.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { client } from '@/lib/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
|
||||
export function useAllTools(enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.tools.all,
|
||||
queryFn: async () => {
|
||||
const res = await client.api.tools.$get();
|
||||
if (!res.ok) throw new Error('Failed to fetch tools');
|
||||
return await res.json();
|
||||
},
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
// Export types using the standard inference pattern
|
||||
export type AllToolsResponse = NonNullable<ReturnType<typeof useAllTools>['data']>;
|
||||
export type ToolInfo = AllToolsResponse['tools'][number];
|
||||
Reference in New Issue
Block a user