godcrm/components/AIChatPanel/hooks/useChatQueries.ts
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

642 lines
25 KiB
TypeScript

/**
* useChatQueries Hook
* ADR-097 Phase 2: Extracted ALL React Query / data-fetching logic from AIChatPanel.tsx
*
* This hook centralizes:
* - Space users (contacts) fetching
* - All users fetching (when "show all" is enabled)
* - Inbox: total unread count + inbox conversations
* - Mark-as-read mutation
* - Task rows, task status dictionary, task table columns
* - Project files for file picker
* - AI operators (providers) and models
* - AI tables creation mutation
* - Users for @mentions
* - AI agents for /commands
* - User conversation message sending mutation
* - User/group conversation messages (useConversationMessages)
* - AI agent conversation messages (useConversationMessages)
*/
import { useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/shared/utils/apiClient';
import { logger } from '@/shared/utils/logger';
import { useCurrentSpace } from '@/features/spaces/store/spacesStore';
import { useAllTables } from '@/features/tables/hooks/useAllTables';
import { useAIChat } from '../../../context/AIChatContext';
import { useConversationMessages } from '../../../hooks/useConversationMessages';
import { CHAT_CONFIG } from '../../../constants/chatConfig';
import { filesApi, type FileModel } from '@/features/files/api/filesApi';
import { MentionUser } from '../../MentionInput';
import type {
ChatMessage,
PanelTab,
TasksSourceConfig,
FilesSourceConfig,
ChatPartner,
} from '../types';
// ─── API Response Wrappers ────────────────────────────────────────────────────
interface ApiResponse<T> {
success: boolean;
data: T;
error?: { code: string; message: string };
}
// ─── User types from the space API ────────────────────────────────────────────
interface SpaceUser {
id: number;
name: string;
email?: string;
avatar_url?: string;
managed_by_agent_table_id?: number;
}
// ─── Inbox conversation type ──────────────────────────────────────────────────
interface InboxConversation {
id: number;
title: string | null;
type: string;
agent_id?: number;
unread_count: number;
updated_at: string;
participants: Array<{
user_id: number;
name: string;
email?: string;
avatar_url?: string;
}>;
sub_agents?: Array<{ row_id: number; name: string; icon?: string | null; response_mode?: string }>;
}
// ─── Task-related types ───────────────────────────────────────────────────────
interface TaskStatusEntry {
id: number;
name?: string;
color?: string;
}
interface TableColumn {
column_name: string;
display_name: string;
type: string;
config?: string;
}
// ─── AI Provider / Model types ────────────────────────────────────────────────
interface AIOperator {
id: number;
name: string;
base_url?: string;
api_key?: string;
icon?: string;
}
interface AIModel {
id: number;
name: string;
model_id?: string;
context_window?: number;
}
// ─── Agent for /commands ──────────────────────────────────────────────────────
interface AgentForSlash {
id: number;
name: string;
icon?: string;
description?: string;
status?: string;
}
// ─── Hook Parameters ──────────────────────────────────────────────────────────
export interface UseChatQueriesParams {
/** Effective space ID (contextSpaceId ?? currentSpace?.id) */
effectiveSpaceId: number | undefined;
/** Currently active side-panel tab */
activePanel: PanelTab;
/** Whether "show all contacts" toggle is on */
showAllContacts: boolean;
/** Tasks source configuration */
tasksSource: TasksSourceConfig | undefined;
/** Files source configuration */
filesSource: FilesSourceConfig | undefined;
/** Whether file picker is open */
showFilePicker: boolean;
/** Currently selected operator ID for model queries */
chatOperatorId: number | null;
/** Current agent (from AIChatContext) */
currentAgent: { id: number; operator_id?: number; provider_id?: number } | null;
/** Current chat partner */
chatPartner: ChatPartner | null;
/** User/group conversation ID */
userConversationId: number | null;
/** Current AI conversation ID (from AIChatContext) */
currentConversationId: number | null;
/** Whether AI agent is currently processing */
isAgentProcessing: boolean;
/** Current user ID */
currentUserId: number | undefined;
/** Callback to reload agents after table creation */
onAgentsReload: () => void;
}
// ─── Hook Return Type ─────────────────────────────────────────────────────────
export interface UseChatQueriesResult {
// ── Contacts ──
spaceUsers: SpaceUser[];
isLoadingSpaceUsers: boolean;
allUsers: SpaceUser[];
isLoadingAllUsers: boolean;
// ── Inbox ──
totalUnreadCount: number;
refetchUnread: () => void;
inboxConversations: InboxConversation[];
isLoadingInbox: boolean;
refetchInbox: () => void;
markAsReadMutation: ReturnType<typeof useMutation<void, Error, number>>;
// ── Tasks ──
taskRows: Array<{ id: number; data: Record<string, unknown> }>;
isLoadingTasks: boolean;
taskStatusDict: TaskStatusEntry[];
tasksTableColumns: TableColumn[];
// ── Files ──
projectFiles: FileModel[];
isLoadingFiles: boolean;
// ── AI Providers & Models ──
operators: AIOperator[];
models: AIModel[];
// ── AI Table Creation ──
createTablesMutation: ReturnType<typeof useMutation<unknown, Error>>;
// ── Mentions ──
usersForMentions: SpaceUser[];
availableMentionUsers: MentionUser[];
// ── Slash Commands ──
availableSlashAgents: MentionUser[];
// ── User Conversation Messages ──
userConversationData: unknown;
userConversationMessages: ChatMessage[];
hasOlderMessages: boolean;
fetchOlderMessages: () => void;
isFetchingOlderMessages: boolean;
refetchUserMessages: () => void;
markAsRead: () => void;
userPollingError: string | null;
userPollingStopped: boolean;
userReconnect: () => void;
// ── AI Agent Conversation Messages ──
aiConversationMessages: ChatMessage[];
aiConversationData: unknown;
isLoadingAIMessages: boolean;
fetchNextAIPage: () => void;
hasNextAIPage: boolean;
isFetchingNextAIPage: boolean;
aiPollingError: string | null;
aiPollingStopped: boolean;
aiReconnect: () => void;
// ── Send User Message ──
sendUserMessageMutation: ReturnType<typeof useMutation<
{ id: number; content: string; sender_id: number } | undefined,
Error,
{ conversationId: number; content: string; agentMode?: 'ask' | 'read' | 'agent'; thinking?: boolean }
>>;
// ── All Tables (for auto-mapping) ──
allTablesData: ReturnType<typeof useAllTables>['data'];
}
// ─── The Hook ─────────────────────────────────────────────────────────────────
export function useChatQueries(params: UseChatQueriesParams): UseChatQueriesResult {
const {
effectiveSpaceId,
activePanel,
showAllContacts,
tasksSource,
filesSource,
showFilePicker,
chatOperatorId,
currentAgent,
chatPartner,
userConversationId,
currentConversationId,
isAgentProcessing,
currentUserId,
onAgentsReload,
} = params;
const currentSpace = useCurrentSpace();
const queryClient = useQueryClient();
// ── All Tables (for auto-mapping in settings) ──────────────────────────────
const { data: allTablesData } = useAllTables();
// ═══════════════════════════════════════════════════════════════════════════
// CONTACTS
// ═══════════════════════════════════════════════════════════════════════════
const { data: spaceUsers = [], isLoading: isLoadingSpaceUsers } = useQuery({
queryKey: ['chat-space-users', effectiveSpaceId],
queryFn: async () => {
if (!effectiveSpaceId) {
logger.debug('[useChatQueries] spaceUsers query: no spaceId available');
return [];
}
logger.debug('[useChatQueries] Fetching space users for space:', effectiveSpaceId);
const response = await apiClient.get<{ success: boolean; data: { users: SpaceUser[]; source: string; table_id: number | null } }>(
`/access/space/${effectiveSpaceId}/available-users`
);
logger.debug('[useChatQueries] Space users response:', response.success, response.data?.users?.length);
return response.success && response.data?.users ? response.data.users : [];
},
enabled: activePanel === 'contacts' && !!effectiveSpaceId,
});
const { data: allUsers = [], isLoading: isLoadingAllUsers } = useQuery({
queryKey: ['chat-all-users'],
queryFn: async () => {
const response = await apiClient.get<{ success: boolean; data: SpaceUser[] }>('/users');
return response.success ? response.data : [];
},
enabled: activePanel === 'contacts' && showAllContacts,
});
// ═══════════════════════════════════════════════════════════════════════════
// INBOX: Unread Count + Conversations
// ═══════════════════════════════════════════════════════════════════════════
const { data: unreadData, refetch: refetchUnread } = useQuery({
queryKey: ['chat-unread-total'],
queryFn: async () => {
const response = await apiClient.get<ApiResponse<{ total_unread: number }>>('/chat/unread');
return response?.data || { total_unread: 0 };
},
refetchInterval: 30000,
});
const totalUnreadCount = unreadData?.total_unread || 0;
const { data: inboxConversations = [], isLoading: isLoadingInbox, refetch: refetchInbox } = useQuery({
queryKey: ['inbox-conversations'],
queryFn: async () => {
const response = await apiClient.get<ApiResponse<InboxConversation[]>>('/chat/conversations');
return response?.data || [];
},
enabled: activePanel === 'inbox',
});
const markAsReadMutation = useMutation({
mutationFn: async (conversationId: number) => {
await apiClient.post(`/chat/conversations/${conversationId}/read`);
},
onSuccess: () => {
refetchUnread();
refetchInbox();
},
});
// ═══════════════════════════════════════════════════════════════════════════
// TASKS
// ═══════════════════════════════════════════════════════════════════════════
const { data: taskRows = [], isLoading: isLoadingTasks } = useQuery({
queryKey: ['task-rows', tasksSource?.tableId],
queryFn: async () => {
if (!tasksSource?.tableId) return [];
const response = await apiClient.get<{
success: boolean;
data: { rows: Array<{ id: number; data: Record<string, unknown> }> };
}>(`/tables/${tasksSource.tableId}/rows?limit=100`);
return response.success ? response.data.rows : [];
},
enabled: !!tasksSource?.tableId && activePanel === 'tasks',
staleTime: 30_000,
});
const { data: taskStatusDict = [] } = useQuery<TaskStatusEntry[]>({
queryKey: ['task-status-dict', tasksSource?.statusDictTableId],
queryFn: async () => {
const response = await apiClient.get<{
success: boolean;
data: { rows: Array<{ id: number; data: Record<string, unknown> }> };
}>(`/tables/${tasksSource!.statusDictTableId}/rows`);
if (!response.success) return [];
return (response.data.rows || []).map(r => ({
id: r.id,
name: String(r.data['name'] || r.data['title'] || ''),
color: r.data['color'] as string | undefined,
}));
},
enabled: !!tasksSource?.statusDictTableId,
staleTime: 5 * 60_000,
});
const { data: tasksTableColumns = [] } = useQuery<TableColumn[]>({
queryKey: ['tasks-table-columns', tasksSource?.tableId],
queryFn: async () => {
const response = await apiClient.get<{
success: boolean;
data: TableColumn[];
}>(`/tables/${tasksSource!.tableId}/columns`);
return response.success ? (response.data || []) : [];
},
enabled: !!tasksSource?.tableId,
staleTime: 5 * 60_000,
});
// ═══════════════════════════════════════════════════════════════════════════
// FILES
// ═══════════════════════════════════════════════════════════════════════════
const { data: projectFiles = [], isLoading: isLoadingFiles } = useQuery({
queryKey: ['project-files', filesSource?.projectId],
queryFn: async () => {
if (!filesSource?.projectId) return [];
const response = await filesApi.list({ projectId: filesSource.projectId, limit: 100 });
return response.files || [];
},
enabled: !!filesSource?.projectId && showFilePicker,
});
// ═══════════════════════════════════════════════════════════════════════════
// AI PROVIDERS & MODELS
// ═══════════════════════════════════════════════════════════════════════════
const { data: operators = [] } = useQuery({
queryKey: ['ai-operators', currentSpace?.id],
queryFn: async () => {
const response = await apiClient.get<{
success: boolean;
data?: { providers: AIOperator[] };
providers?: AIOperator[];
}>(`/ai/providers?spaceId=${currentSpace?.id || ''}`);
return response.data?.providers || response.providers || [];
},
enabled: activePanel === 'settings' || activePanel === 'ai-agents',
});
const { data: models = [] } = useQuery({
queryKey: ['ai-models', chatOperatorId || currentAgent?.operator_id],
queryFn: async () => {
const opId = chatOperatorId || currentAgent?.operator_id;
if (!opId) return [];
const response = await apiClient.get<{
success: boolean;
data?: { models: AIModel[] };
models?: AIModel[];
}>(`/ai/providers/${opId}/models`);
return response.data?.models || response.models || [];
},
enabled:
(activePanel === 'settings' || activePanel === 'ai-agents') &&
!!(chatOperatorId || currentAgent?.operator_id),
});
// ═══════════════════════════════════════════════════════════════════════════
// AI TABLE CREATION
// ═══════════════════════════════════════════════════════════════════════════
const createTablesMutation = useMutation({
mutationFn: async () => {
if (!currentSpace?.id) throw new Error('No space selected');
const response = await apiClient.post<{ success: boolean; tables?: unknown }>('/ai/setup-tables', {
spaceId: currentSpace.id,
});
if (!response.success) throw new Error('Failed to create tables');
return response;
},
onSuccess: () => {
onAgentsReload();
queryClient.invalidateQueries({ queryKey: ['ai-agents'] });
},
});
// ═══════════════════════════════════════════════════════════════════════════
// MENTIONS: Users for @mentions
// ═══════════════════════════════════════════════════════════════════════════
const { data: usersForMentions = [] } = useQuery({
queryKey: ['users-for-mentions', effectiveSpaceId],
queryFn: async () => {
if (!effectiveSpaceId) return [];
const response = await apiClient.get<{
success: boolean;
data: { users: SpaceUser[]; source: string; table_id: number | null };
}>(`/access/space/${effectiveSpaceId}/available-users`);
return response.success && response.data?.users ? response.data.users : [];
},
enabled: !!effectiveSpaceId,
staleTime: 60000,
});
const availableMentionUsers: MentionUser[] = useMemo(() => {
return usersForMentions.map(user => ({
id: user.id,
name: user.name,
email: user.email,
avatar: user.avatar_url,
type: user.managed_by_agent_table_id ? ('bot' as const) : ('human' as const),
}));
}, [usersForMentions]);
// ═══════════════════════════════════════════════════════════════════════════
// SLASH COMMANDS: AI Agents for /commands
// ═══════════════════════════════════════════════════════════════════════════
const { data: aiAgentsData } = useQuery({
queryKey: ['ai-agents-for-slash', effectiveSpaceId],
queryFn: async () => {
const response = await apiClient.get<{
success: boolean;
data: { agents: AgentForSlash[] };
}>(`/ai/agents/${effectiveSpaceId}`);
return response;
},
enabled: !!effectiveSpaceId,
staleTime: 60000,
});
const availableSlashAgents: MentionUser[] = useMemo(() => {
const agentsList = aiAgentsData?.data?.agents || [];
return agentsList
.filter(agent => agent.status !== 'inactive' && agent.name)
.map(agent => ({
id: agent.id,
name: agent.name,
icon: agent.icon,
email: agent.description,
type: 'agent' as const,
}));
}, [aiAgentsData]);
// ═══════════════════════════════════════════════════════════════════════════
// CONVERSATION MESSAGES (User/Group)
// ═══════════════════════════════════════════════════════════════════════════
const {
conversation: userConversationData,
messages: userConversationMessages,
hasNextPage: hasOlderMessages,
fetchNextPage: fetchOlderMessages,
isFetchingNextPage: isFetchingOlderMessages,
refetch: refetchUserMessages,
markAsRead,
pollingError: userPollingError,
pollingStopped: userPollingStopped,
reconnect: userReconnect,
} = useConversationMessages(userConversationId, {
pageSize: CHAT_CONFIG.MESSAGE_PAGE_SIZE,
enabled: !!userConversationId && (chatPartner?.type === 'user' || chatPartner?.type === 'group'),
adaptivePolling: true,
chatActivityState: 'active',
currentUserId,
});
// ═══════════════════════════════════════════════════════════════════════════
// CONVERSATION MESSAGES (AI Agent)
// ═══════════════════════════════════════════════════════════════════════════
const {
messages: aiConversationMessages,
conversation: aiConversationData,
isLoading: isLoadingAIMessages,
fetchNextPage: fetchNextAIPage,
hasNextPage: hasNextAIPage,
isFetchingNextPage: isFetchingNextAIPage,
pollingError: aiPollingError,
pollingStopped: aiPollingStopped,
reconnect: aiReconnect,
} = useConversationMessages(currentConversationId, {
pageSize: CHAT_CONFIG.MESSAGE_PAGE_SIZE,
enabled: !!currentConversationId && chatPartner?.type === 'agent',
adaptivePolling: true,
chatActivityState: isAgentProcessing ? 'agent_processing' : 'idle',
currentUserId,
});
// ═══════════════════════════════════════════════════════════════════════════
// SEND USER MESSAGE
// ═══════════════════════════════════════════════════════════════════════════
const sendUserMessageMutation = useMutation({
mutationFn: async ({
conversationId,
content,
agentMode: mode,
thinking,
mentions,
attachments,
}: {
conversationId: number;
content: string;
agentMode?: 'ask' | 'read' | 'agent';
thinking?: boolean;
mentions?: Array<{ user_id: number; name?: string; type?: string }>;
attachments?: Array<{ id: string; name: string; type: string; size: number; url?: string; preview?: string }>;
}) => {
const response = await apiClient.post<
ApiResponse<{ id: number; content: string; sender_id: number }>
>(`/chat/conversations/${conversationId}/messages`, {
content,
content_type: 'text',
...(mode && { agent_mode: mode }),
...(thinking !== undefined && { thinking_enabled: thinking }),
...(mentions && mentions.length > 0 && { mentions }),
...(attachments && attachments.length > 0 && { attachments }),
});
return response?.data;
},
onSuccess: () => {
refetchUserMessages();
},
});
// ─── Return ─────────────────────────────────────────────────────────────────
return {
// Contacts
spaceUsers,
isLoadingSpaceUsers,
allUsers,
isLoadingAllUsers,
// Inbox
totalUnreadCount,
refetchUnread,
inboxConversations,
isLoadingInbox,
refetchInbox,
markAsReadMutation,
// Tasks
taskRows,
isLoadingTasks,
taskStatusDict,
tasksTableColumns,
// Files
projectFiles,
isLoadingFiles,
// AI Providers & Models
operators,
models,
// AI Table Creation
createTablesMutation,
// Mentions
usersForMentions,
availableMentionUsers,
// Slash Commands
availableSlashAgents,
// User Conversation Messages
userConversationData,
userConversationMessages: (userConversationMessages || []) as ChatMessage[],
hasOlderMessages: !!hasOlderMessages,
fetchOlderMessages,
isFetchingOlderMessages,
refetchUserMessages,
markAsRead,
userPollingError: userPollingError ?? null,
userPollingStopped: !!userPollingStopped,
userReconnect,
// AI Agent Conversation Messages
aiConversationMessages: (aiConversationMessages || []) as ChatMessage[],
aiConversationData,
isLoadingAIMessages,
fetchNextAIPage,
hasNextAIPage: !!hasNextAIPage,
isFetchingNextAIPage,
aiPollingError: aiPollingError ?? null,
aiPollingStopped: !!aiPollingStopped,
aiReconnect,
// Send User Message
sendUserMessageMutation,
// All Tables
allTablesData,
};
}