067fc46170
PR 2 из 2 (#381, фаза 1.5 #184). Все вкладки теперь на ОДНОМ транспорте: любая подключается к рану через GET-attach (реплей кадров + живой хвост, реестр из PR 1). Наблюдатель — обычный стример; Stop, точки, инвалидации работают штатно. Двухпутёвый поллинг снапшотов и вся latch-механика F4/F5/F7 удалены. - utils/resume-helpers.ts (новый): isStreamingTail / isSettledAssistantTail / seedRows / mergeById (дословный перенос mergeObservedMessage из run-polling). - components/chat-thread.tsx: resume-машинерия (гейтинг по не-settled хвосту, strip streaming-хвоста + attach ?expect=live&anchor=<row id>, транспорт prepareReconnectToStreamRequest+fetch, 204-обработчик из 4 частей, reconcile+degraded-merge, recovery с АСИММЕТРИЕЙ arm-vs-restore — при isDisconnect с видимым контентом только arm, без restore-клоббера живого стрима (инв. 9), строгий порядок onFinish с ранним return до обеих веток отправки (инв. 7), «Send now» скрыт на resumed-ходе, Stop абортит attach). - components/ai-chat-window.tsx: degraded-poll фолбэк вместо латчей — тупой таймер (2500ms, 10-мин кап, без проверок ошибок/хвоста; переживает рестарт сервера), гасится тредом через onResumeFallback(false). - Удалено: run-polling.ts(+test), useAiChatRunQuery/AI_CHAT_RUN_RQ_KEY, getAiChatRun (stopRun оставлен), IAiChatRun/IAiChatRunResponse, латчи stoppingRun/localStreaming/observedRow/onStreamingChange, F7-эффект, observer-merge. Серверный POST /ai-chat/run не тронут. Проверка: tsc (мои файлы чисты), vitest src/features/ai-chat 34 файла/301 тест зелёные, grep-guard по удалённым символам пуст. Отдельное внутреннее ревью на инварианты 7/8/9 + 204-null-safety — чисто. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
390 lines
13 KiB
TypeScript
390 lines
13 KiB
TypeScript
import {
|
|
useInfiniteQuery,
|
|
useMutation,
|
|
useQueries,
|
|
useQuery,
|
|
useQueryClient,
|
|
} from "@tanstack/react-query";
|
|
import { useEffect, useMemo } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { notifications } from "@mantine/notifications";
|
|
import {
|
|
createAiRole,
|
|
deleteAiChat,
|
|
deleteAiRole,
|
|
getAiChatMessages,
|
|
getAiChats,
|
|
getAiRoleCatalog,
|
|
getAiRoleCatalogBundle,
|
|
getAiRoles,
|
|
importAiRolesFromCatalog,
|
|
renameAiChat,
|
|
updateAiRole,
|
|
updateAiRoleFromCatalog,
|
|
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
|
import {
|
|
IAiChat,
|
|
IAiChatMessageRow,
|
|
IAiRole,
|
|
IAiRoleCatalog,
|
|
IAiRoleCatalogBundle,
|
|
IAiRoleCreate,
|
|
IAiRoleImportPayload,
|
|
IAiRoleImportResult,
|
|
IAiRoleUpdate,
|
|
IAiRoleUpdateFromCatalogResult,
|
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
|
import { IPagination } from "@/lib/types.ts";
|
|
|
|
export const AI_CHATS_RQ_KEY = ["ai-chats"];
|
|
export const AI_ROLES_RQ_KEY = ["ai-roles"];
|
|
// Catalog reads resolve bundle names per language, so the language is part of
|
|
// the cache key (a language switch refetches rather than reusing stale names).
|
|
export const AI_ROLE_CATALOG_RQ_KEY = (language: string) => [
|
|
"ai-role-catalog",
|
|
language,
|
|
];
|
|
export const AI_ROLE_CATALOG_BUNDLE_RQ_KEY = (
|
|
bundleId: string,
|
|
language: string,
|
|
) => ["ai-role-catalog-bundle", bundleId, language];
|
|
export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
|
"ai-chat-messages",
|
|
chatId,
|
|
];
|
|
|
|
/** Paginated list of the current user's chats (auto-loads further pages). */
|
|
export function useAiChatsQuery() {
|
|
const query = useInfiniteQuery({
|
|
queryKey: AI_CHATS_RQ_KEY,
|
|
queryFn: ({ pageParam }) => getAiChats({ cursor: pageParam, limit: 50 }),
|
|
initialPageParam: undefined as string | undefined,
|
|
getNextPageParam: (lastPage) =>
|
|
lastPage.meta.hasNextPage
|
|
? (lastPage.meta.nextCursor ?? undefined)
|
|
: undefined,
|
|
});
|
|
|
|
const data = useMemo<IPagination<IAiChat> | undefined>(() => {
|
|
if (!query.data) return undefined;
|
|
return {
|
|
items: query.data.pages.flatMap((p) => p.items),
|
|
meta: query.data.pages[query.data.pages.length - 1].meta,
|
|
};
|
|
}, [query.data]);
|
|
|
|
return {
|
|
data,
|
|
isLoading: query.isLoading,
|
|
isError: query.isError,
|
|
refetch: query.refetch,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Load all persisted messages of a chat (oldest first), flattening the
|
|
* paginated server response. Used to seed `useChat` initial messages.
|
|
*/
|
|
export function useAiChatMessagesQuery(
|
|
chatId: string | undefined,
|
|
// #184 phase 1.5: the degraded-poll fallback. When a tab could not attach to a
|
|
// still-running run (the attach returned 204 / the resumed stream ended with no
|
|
// terminal row), the window arms a dumb timed poll of the message history to
|
|
// follow the detached run to settle. The callback form lives in AiChatWindow;
|
|
// threaded here verbatim so this query owns the polling. Undefined => no poll.
|
|
refetchInterval?: number | false | (() => number | false),
|
|
) {
|
|
const query = useInfiniteQuery({
|
|
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatId ?? ""),
|
|
queryFn: ({ pageParam }) =>
|
|
getAiChatMessages({ chatId: chatId as string, cursor: pageParam }),
|
|
initialPageParam: undefined as string | undefined,
|
|
getNextPageParam: (lastPage) =>
|
|
lastPage.meta.hasNextPage
|
|
? (lastPage.meta.nextCursor ?? undefined)
|
|
: undefined,
|
|
enabled: !!chatId,
|
|
refetchInterval,
|
|
});
|
|
|
|
// useInfiniteQuery only fetches the first page on its own. The hook's contract
|
|
// (and both the Markdown export and the model-history seed) require the
|
|
// COMPLETE thread, so keep pulling subsequent pages until the server reports
|
|
// none remain. The isFetchingNextPage guard issues one request at a time;
|
|
// when chatId is undefined the query is disabled and hasNextPage is false, so
|
|
// this is a no-op. The isFetchNextPageError guard is critical: the app sets a
|
|
// global `retry: false`, so a rejected fetchNextPage leaves hasNextPage true
|
|
// and isFetchingNextPage false — without this guard the effect would re-fire
|
|
// immediately and hammer the endpoint in a tight loop. isFetchNextPageError
|
|
// latches the last next-page failure and clears once a fetch succeeds.
|
|
useEffect(() => {
|
|
if (
|
|
query.hasNextPage &&
|
|
!query.isFetchingNextPage &&
|
|
!query.isFetchNextPageError
|
|
) {
|
|
void query.fetchNextPage();
|
|
}
|
|
}, [
|
|
query.hasNextPage,
|
|
query.isFetchingNextPage,
|
|
query.isFetchNextPageError,
|
|
query.fetchNextPage,
|
|
]);
|
|
|
|
const data = useMemo<IAiChatMessageRow[] | undefined>(() => {
|
|
if (!query.data) return undefined;
|
|
return query.data.pages.flatMap((p) => p.items);
|
|
}, [query.data]);
|
|
|
|
return {
|
|
data,
|
|
isLoading: query.isLoading || query.hasNextPage,
|
|
isError: query.isError,
|
|
};
|
|
}
|
|
|
|
export function useRenameAiChatMutation() {
|
|
const queryClient = useQueryClient();
|
|
const { t } = useTranslation();
|
|
|
|
return useMutation<void, Error, { chatId: string; title: string }>({
|
|
mutationFn: (data) => renameAiChat(data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
|
},
|
|
onError: () => {
|
|
notifications.show({
|
|
message: t("Failed to rename chat"),
|
|
color: "red",
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useDeleteAiChatMutation() {
|
|
const queryClient = useQueryClient();
|
|
const { t } = useTranslation();
|
|
|
|
return useMutation<void, Error, string>({
|
|
mutationFn: (chatId) => deleteAiChat(chatId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
|
},
|
|
onError: () => {
|
|
notifications.show({
|
|
message: t("Failed to delete chat"),
|
|
color: "red",
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* List the workspace's agent roles. Available to any workspace member (used by
|
|
* the chat-creation role picker and the admin management section). `enabled`
|
|
* lets callers gate the fetch (e.g. only fetch in the settings section).
|
|
*/
|
|
export function useAiRolesQuery(enabled: boolean = true) {
|
|
return useQuery<IAiRole[], Error>({
|
|
queryKey: AI_ROLES_RQ_KEY,
|
|
queryFn: () => getAiRoles(),
|
|
enabled,
|
|
});
|
|
}
|
|
|
|
export function useCreateAiRoleMutation() {
|
|
const queryClient = useQueryClient();
|
|
const { t } = useTranslation();
|
|
|
|
return useMutation<IAiRole, Error, IAiRoleCreate>({
|
|
mutationFn: (data) => createAiRole(data),
|
|
onSuccess: () => {
|
|
notifications.show({ message: t("Created successfully") });
|
|
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
|
},
|
|
onError: (error) => {
|
|
const message = error["response"]?.data?.message;
|
|
notifications.show({
|
|
message: message ?? t("Failed to update data"),
|
|
color: "red",
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useUpdateAiRoleMutation() {
|
|
const queryClient = useQueryClient();
|
|
const { t } = useTranslation();
|
|
|
|
return useMutation<IAiRole, Error, IAiRoleUpdate>({
|
|
mutationFn: (data) => updateAiRole(data),
|
|
onSuccess: () => {
|
|
notifications.show({ message: t("Updated successfully") });
|
|
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
|
// The role badge denormalized onto the chat list may have changed.
|
|
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
|
},
|
|
onError: (error) => {
|
|
const message = error["response"]?.data?.message;
|
|
notifications.show({
|
|
message: message ?? t("Failed to update data"),
|
|
color: "red",
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useDeleteAiRoleMutation() {
|
|
const queryClient = useQueryClient();
|
|
const { t } = useTranslation();
|
|
|
|
return useMutation<{ success: true }, Error, string>({
|
|
mutationFn: (id) => deleteAiRole(id),
|
|
onSuccess: () => {
|
|
notifications.show({ message: t("Deleted successfully") });
|
|
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
|
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
|
},
|
|
onError: (error) => {
|
|
const message = error["response"]?.data?.message;
|
|
notifications.show({
|
|
message: message ?? t("Failed to update data"),
|
|
color: "red",
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Browse the role catalog for a language. Gated by `enabled` so the (admin-only)
|
|
* fetch runs only when the catalog modal is open. The catalog can 502 when the
|
|
* curated source is unreachable; callers handle the error state in the UI.
|
|
*/
|
|
export function useAiRoleCatalogQuery(language: string, enabled: boolean) {
|
|
return useQuery<IAiRoleCatalog, Error>({
|
|
queryKey: AI_ROLE_CATALOG_RQ_KEY(language),
|
|
queryFn: () => getAiRoleCatalog(language),
|
|
enabled,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Open one catalog bundle (role content + versions). Gated by `enabled` so the
|
|
* fetch only runs when a bundle is actually expanded.
|
|
*/
|
|
export function useAiRoleCatalogBundleQuery(
|
|
bundleId: string,
|
|
language: string,
|
|
enabled: boolean,
|
|
) {
|
|
return useQuery<IAiRoleCatalogBundle, Error>({
|
|
queryKey: AI_ROLE_CATALOG_BUNDLE_RQ_KEY(bundleId, language),
|
|
queryFn: () => getAiRoleCatalogBundle(bundleId, language),
|
|
enabled,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Eagerly open EVERY listed bundle's content in parallel for one language. The
|
|
* redesigned catalog shows each bundle's status summary in its COLLAPSED header,
|
|
* which needs every role's install state up front — so contents can no longer be
|
|
* lazy-loaded on expand. The catalog is small, so a fan-out of `useQueries` (one
|
|
* cached read per bundle, sharing the same cache keys as
|
|
* `useAiRoleCatalogBundleQuery`) is cheap. Gated by `enabled` (modal open + a
|
|
* resolved language) so nothing fetches while the modal is closed.
|
|
*/
|
|
export function useAiRoleCatalogBundlesQueries(
|
|
bundleIds: string[],
|
|
language: string,
|
|
enabled: boolean,
|
|
) {
|
|
return useQueries({
|
|
queries: bundleIds.map((bundleId) => ({
|
|
queryKey: AI_ROLE_CATALOG_BUNDLE_RQ_KEY(bundleId, language),
|
|
queryFn: () => getAiRoleCatalogBundle(bundleId, language),
|
|
enabled: enabled && !!language,
|
|
})),
|
|
});
|
|
}
|
|
|
|
export function useImportAiRolesFromCatalogMutation() {
|
|
const queryClient = useQueryClient();
|
|
const { t } = useTranslation();
|
|
|
|
return useMutation<IAiRoleImportResult, Error, IAiRoleImportPayload>({
|
|
mutationFn: (payload) => importAiRolesFromCatalog(payload),
|
|
onSuccess: (result) => {
|
|
notifications.show({
|
|
message: t(
|
|
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}",
|
|
{
|
|
created: result.created,
|
|
renamed: result.renamed,
|
|
skipped: result.skipped,
|
|
},
|
|
),
|
|
});
|
|
// Surface partial failures (e.g. unique-name races) as a red warning.
|
|
if (result.errors.length > 0) {
|
|
notifications.show({
|
|
color: "red",
|
|
message: t("Failed to import {{count}} role(s)", {
|
|
count: result.errors.length,
|
|
}),
|
|
});
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
|
// Imported roles can appear in the chat picker / badges.
|
|
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
|
},
|
|
onError: (error) => {
|
|
const message = error["response"]?.data?.message;
|
|
notifications.show({
|
|
message: message ?? t("Failed to update data"),
|
|
color: "red",
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useUpdateAiRoleFromCatalogMutation() {
|
|
const queryClient = useQueryClient();
|
|
const { t } = useTranslation();
|
|
|
|
return useMutation<IAiRoleUpdateFromCatalogResult, Error, string>({
|
|
mutationFn: (id) => updateAiRoleFromCatalog(id),
|
|
onSuccess: (result) => {
|
|
// The server returns updated:false with a reason for a no-op (already
|
|
// up to date / removed from catalog / language no longer offered). Map
|
|
// each reason to a specific message instead of a generic "up to date".
|
|
// Narrow the discriminated union via `"reason" in result` (the `updated`
|
|
// boolean discriminant does not narrow under this project's
|
|
// strictNullChecks:false). Inside the branch, `reason` is the typed literal
|
|
// union, so the comparisons below are compiler-checked.
|
|
let message: string;
|
|
if (!("reason" in result)) {
|
|
message = t("Updated to the latest version");
|
|
} else if (result.reason === "not-in-catalog") {
|
|
message = t("This role is no longer in the catalog");
|
|
} else if (result.reason === "language-unavailable") {
|
|
message = t("This language is no longer available in the catalog");
|
|
} else {
|
|
// "up-to-date" (the only remaining reason).
|
|
message = t("Already up to date");
|
|
}
|
|
notifications.show({ message });
|
|
queryClient.invalidateQueries({ queryKey: AI_ROLES_RQ_KEY });
|
|
// The role badge denormalized onto the chat list may have changed.
|
|
queryClient.invalidateQueries({ queryKey: AI_CHATS_RQ_KEY });
|
|
},
|
|
onError: (error) => {
|
|
const message = error["response"]?.data?.message;
|
|
notifications.show({
|
|
message: message ?? t("Failed to update data"),
|
|
color: "red",
|
|
});
|
|
},
|
|
});
|
|
}
|