Files
simstudioai--sim/apps/sim/hooks/queries/chats.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

375 lines
10 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import {
authenticateDeployedChatContract,
type ChatAuthType,
type CreateChatBody,
type CreateChatResponse,
createChatContract,
type DeployedChatAuthBody,
type DeployedChatConfig,
deleteChatContract,
getDeployedChatConfigContract,
requestChatEmailOtpContract,
type UpdateChatBody,
type UpdateChatResponse,
updateChatContract,
verifyChatEmailOtpContract,
} from '@/lib/api/contracts/chats'
import type { OutputConfig } from '@/stores/chat/types'
import { deploymentKeys } from './deployments'
const logger = createLogger('ChatMutations')
/**
* Query keys for chat-related queries
*/
export const chatKeys = {
all: ['chats'] as const,
status: deploymentKeys.chatStatus,
detail: deploymentKeys.chatDetail,
configs: () => [...chatKeys.all, 'config'] as const,
config: (identifier?: string) => [...chatKeys.configs(), identifier ?? ''] as const,
}
export const DEPLOYED_CHAT_CONFIG_STALE_TIME = 60 * 1000
/**
* Auth types for chat access control
*/
export type AuthType = ChatAuthType
/** Deployed chat configuration returned from the public chat endpoint. */
export type { DeployedChatConfig }
/**
* Result of loading a deployed chat's configuration.
* When the endpoint responds 401 with an auth_required_* error, the query
* succeeds with `kind: 'auth'` so consumers can render the auth form without
* treating it as a fetch error.
*/
export type DeployedChatConfigResult =
| { kind: 'config'; config: DeployedChatConfig }
| { kind: 'auth'; authType: 'password' | 'email' | 'sso' }
const AUTH_ERROR_MAP: Record<string, 'password' | 'email' | 'sso'> = {
auth_required_password: 'password',
auth_required_email: 'email',
auth_required_sso: 'sso',
}
async function fetchDeployedChatConfig(
identifier: string,
signal?: AbortSignal
): Promise<DeployedChatConfigResult> {
try {
const config = await requestJson(getDeployedChatConfigContract, {
params: { identifier },
signal,
})
return { kind: 'config', config }
} catch (error) {
if (error instanceof ApiClientError && error.status === 401) {
const authType = AUTH_ERROR_MAP[error.message]
if (authType) {
return { kind: 'auth', authType }
}
throw new Error('Unauthorized', { cause: error })
}
throw error
}
}
/**
* Loads the public chat configuration for a deployed chat identifier.
* Resolves to `{ kind: 'auth', authType }` when the chat requires
* password/email/SSO gating so the consumer can render the appropriate form.
*/
export function useDeployedChatConfig(identifier: string) {
return useQuery({
queryKey: chatKeys.config(identifier),
queryFn: ({ signal }) => fetchDeployedChatConfig(identifier, signal),
enabled: Boolean(identifier),
staleTime: DEPLOYED_CHAT_CONFIG_STALE_TIME,
retry: false,
})
}
async function postChatAuth(
identifier: string,
body: DeployedChatAuthBody
): Promise<DeployedChatConfig> {
return requestJson(authenticateDeployedChatContract, {
params: { identifier },
body,
})
}
/**
* Authenticates against a password-gated deployed chat. On success, seeds the
* config query cache with the returned chat config so the consumer can render
* the chat immediately without a follow-up GET.
*/
export function useChatPasswordAuth(identifier: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ password }: { password: string }) => postChatAuth(identifier, { password }),
onSuccess: (config) => {
queryClient.setQueryData<DeployedChatConfigResult>(chatKeys.config(identifier), {
kind: 'config',
config,
})
},
})
}
/**
* Requests a one-time passcode for an email-gated deployed chat.
* Used for both the initial send and resend flows.
*/
export function useChatEmailOtpRequest(identifier: string) {
return useMutation({
mutationFn: async ({ email }: { email: string }) => {
await requestJson(requestChatEmailOtpContract, {
params: { identifier },
body: { email },
})
},
})
}
/**
* Verifies a one-time passcode for an email-gated deployed chat. On success,
* seeds the config query cache with the returned chat config.
*/
export function useChatEmailOtpVerify(identifier: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ email, otp }: { email: string; otp: string }) => {
return requestJson(verifyChatEmailOtpContract, {
params: { identifier },
body: { email, otp },
})
},
onSuccess: (config) => {
queryClient.setQueryData<DeployedChatConfigResult>(chatKeys.config(identifier), {
kind: 'config',
config,
})
},
})
}
/**
* Form data for creating/updating a chat
*/
export interface ChatFormData {
identifier: string
title: string
description: string
authType: AuthType
password: string
emails: string[]
welcomeMessage: string
selectedOutputBlocks: string[]
}
/**
* Variables for create chat mutation
*/
interface CreateChatVariables {
workflowId: string
formData: ChatFormData
imageUrl?: string | null
}
/**
* Variables for update chat mutation
*/
interface UpdateChatVariables {
chatId: string
workflowId: string
formData: ChatFormData
imageUrl?: string | null
}
/**
* Variables for delete chat mutation
*/
interface DeleteChatVariables {
chatId: string
workflowId: string
}
/**
* Data returned by chat create/update mutations
*/
type ChatMutationData =
| Pick<CreateChatResponse, 'chatUrl' | 'chatId'>
| Pick<UpdateChatResponse, 'chatUrl'>
function throwUserFriendlyIdentifierError(error: unknown): never {
if (error instanceof ApiClientError && error.message === 'Identifier already in use') {
throw new Error('This identifier is already in use', { cause: error })
}
throw error
}
/**
* Parses output block selections into structured output configs
*/
function parseOutputConfigs(selectedOutputBlocks: string[]): OutputConfig[] {
return selectedOutputBlocks
.map((outputId) => {
const firstUnderscoreIndex = outputId.indexOf('_')
if (firstUnderscoreIndex !== -1) {
const blockId = outputId.substring(0, firstUnderscoreIndex)
const path = outputId.substring(firstUnderscoreIndex + 1)
if (blockId && path) {
return { blockId, path }
}
}
return null
})
.filter((config): config is OutputConfig => config !== null)
}
/**
* Build chat payload from form data
*/
function buildChatPayload(
workflowId: string,
formData: ChatFormData,
imageUrl?: string | null
): CreateChatBody {
const outputConfigs = parseOutputConfigs(formData.selectedOutputBlocks)
return {
workflowId,
identifier: formData.identifier.trim(),
title: formData.title.trim(),
description: formData.description.trim(),
customizations: {
primaryColor: 'var(--brand-hover)',
welcomeMessage: formData.welcomeMessage.trim(),
...(imageUrl && { imageUrl }),
},
authType: formData.authType,
password: formData.authType === 'password' ? formData.password : undefined,
allowedEmails:
formData.authType === 'email' || formData.authType === 'sso' ? formData.emails : [],
outputConfigs,
}
}
/**
* Mutation hook for creating a new chat deployment.
* Invalidates chat status and detail queries on success.
*/
export function useCreateChat() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
workflowId,
formData,
imageUrl,
}: CreateChatVariables): Promise<ChatMutationData> => {
const payload = buildChatPayload(workflowId, formData, imageUrl)
try {
const result = await requestJson(createChatContract, { body: payload })
logger.info('Chat deployed successfully:', result.chatUrl)
return { chatUrl: result.chatUrl, chatId: result.chatId }
} catch (error) {
throwUserFriendlyIdentifierError(error)
}
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: deploymentKeys.chatStatus(variables.workflowId),
})
queryClient.invalidateQueries({
queryKey: deploymentKeys.info(variables.workflowId),
})
queryClient.invalidateQueries({
queryKey: deploymentKeys.versions(variables.workflowId),
})
},
onError: (error) => {
logger.error('Failed to create chat', { error })
},
})
}
/**
* Mutation hook for updating an existing chat deployment.
* Invalidates chat status and detail queries on success.
*/
export function useUpdateChat() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
chatId,
workflowId,
formData,
imageUrl,
}: UpdateChatVariables): Promise<ChatMutationData> => {
const payload = buildChatPayload(workflowId, formData, imageUrl)
try {
const result = await requestJson(updateChatContract, {
params: { id: chatId },
body: payload satisfies UpdateChatBody,
})
logger.info('Chat updated successfully:', result.chatUrl)
return { chatUrl: result.chatUrl, chatId }
} catch (error) {
throwUserFriendlyIdentifierError(error)
}
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: deploymentKeys.chatStatus(variables.workflowId),
})
queryClient.invalidateQueries({
queryKey: deploymentKeys.chatDetail(variables.chatId),
})
},
onError: (error) => {
logger.error('Failed to update chat', { error })
},
})
}
/**
* Mutation hook for deleting a chat deployment.
* Invalidates chat status and removes chat detail from cache on success.
*/
export function useDeleteChat() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ chatId }: DeleteChatVariables): Promise<void> => {
await requestJson(deleteChatContract, { params: { id: chatId } })
logger.info('Chat deleted successfully')
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: deploymentKeys.chatStatus(variables.workflowId),
})
queryClient.removeQueries({
queryKey: deploymentKeys.chatDetail(variables.chatId),
})
},
onError: (error) => {
logger.error('Failed to delete chat', { error })
},
})
}