Files
wehub-resource-sync d25d482dc2
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

289 lines
9.6 KiB
TypeScript

import { db } from '@sim/db'
import { copilotChats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
deleteMothershipChatContract,
getMothershipChatContract,
updateMothershipChatContract,
} from '@/lib/api/contracts/mothership-chats'
import { parseRequest } from '@/lib/api/server'
import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository'
import { buildEffectiveChatTranscript } from '@/lib/copilot/chat/effective-transcript'
import {
getAccessibleCopilotChatAuth,
getAccessibleCopilotChatWithMessages,
} from '@/lib/copilot/chat/lifecycle'
import { normalizeMessage } from '@/lib/copilot/chat/persisted-message'
import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness'
import { chatPubSub } from '@/lib/copilot/chat-status'
import {
authenticateCopilotRequestSessionOnly,
createInternalServerErrorResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import type { FilePreviewSession } from '@/lib/copilot/request/session'
import { readEvents } from '@/lib/copilot/request/session/buffer'
import { readFilePreviewSessions } from '@/lib/copilot/request/session/file-preview-session'
import { type StreamBatchEvent, toStreamBatchEvent } from '@/lib/copilot/request/session/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
const logger = createLogger('MothershipChatAPI')
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const paramsResult = await parseRequest(getMothershipChatContract, request, context)
if (!paramsResult.success) return paramsResult.response
const { chatId } = paramsResult.data.params
const chat = await getAccessibleCopilotChatWithMessages(chatId, userId)
if (!chat || chat.type !== 'mothership') {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
let streamSnapshot: {
events: StreamBatchEvent[]
previewSessions: FilePreviewSession[]
status: string
} | null = null
const reconciledMarkers = await reconcileChatStreamMarkers(
[{ chatId: chat.id, streamId: chat.conversationId }],
{ repairVerifiedStaleMarkers: true }
)
const liveStreamId = reconciledMarkers.get(chat.id)?.streamId ?? null
if (liveStreamId) {
try {
const [events, previewSessions] = await Promise.all([
readEvents(liveStreamId, '0'),
readFilePreviewSessions(liveStreamId).catch((error) => {
logger.warn('Failed to read preview sessions for mothership chat', {
chatId,
streamId: liveStreamId,
error: toError(error).message,
})
return []
}),
])
const run = await getLatestRunForStream(liveStreamId, userId).catch((error) => {
logger.warn('Failed to fetch latest run for mothership chat snapshot', {
chatId,
streamId: liveStreamId,
error: toError(error).message,
})
return null
})
streamSnapshot = {
events: events.map(toStreamBatchEvent),
previewSessions,
status:
typeof run?.status === 'string'
? run.status
: events.length > 0
? 'active'
: 'unknown',
}
} catch (error) {
logger.warn('Failed to read stream snapshot for mothership chat', {
chatId,
streamId: liveStreamId,
error: toError(error).message,
})
}
}
const normalizedMessages = Array.isArray(chat.messages)
? chat.messages
.filter((message): message is Record<string, unknown> => Boolean(message))
.map(normalizeMessage)
: []
const effectiveMessages = buildEffectiveChatTranscript({
messages: normalizedMessages,
activeStreamId: liveStreamId,
...(streamSnapshot ? { streamSnapshot } : {}),
})
return NextResponse.json({
success: true,
chat: {
id: chat.id,
title: chat.title,
messages: effectiveMessages,
activeStreamId: liveStreamId,
resources: Array.isArray(chat.resources) ? chat.resources : [],
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
...(streamSnapshot ? { streamSnapshot } : {}),
},
})
} catch (error) {
logger.error('Error fetching mothership chat:', error)
return createInternalServerErrorResponse('Failed to fetch chat')
}
}
)
export const PATCH = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const parsed = await parseRequest(updateMothershipChatContract, request, context)
if (!parsed.success) return parsed.response
const { chatId } = parsed.data.params
const { title, isUnread, pinned } = parsed.data.body
const updates: Record<string, unknown> = {}
if (title !== undefined) {
const now = new Date()
updates.title = title
updates.updatedAt = now
if (isUnread === undefined) {
updates.lastSeenAt = now
}
}
if (isUnread !== undefined) {
updates.lastSeenAt = isUnread ? null : sql`GREATEST(${copilotChats.updatedAt}, NOW())`
}
if (pinned !== undefined) {
updates.pinned = pinned
}
const [updatedChat] = await db
.update(copilotChats)
.set(updates)
.where(
and(
eq(copilotChats.id, chatId),
eq(copilotChats.userId, userId),
eq(copilotChats.type, 'mothership')
)
)
.returning({
id: copilotChats.id,
workspaceId: copilotChats.workspaceId,
})
if (!updatedChat) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
if (updatedChat.workspaceId) {
if (title !== undefined) {
chatPubSub?.publishStatusChanged({
workspaceId: updatedChat.workspaceId,
chatId,
type: 'renamed',
})
captureServerEvent(
userId,
'task_renamed',
{ workspace_id: updatedChat.workspaceId },
{
groups: { workspace: updatedChat.workspaceId },
}
)
}
if (isUnread === true) {
captureServerEvent(
userId,
'task_marked_unread',
{ workspace_id: updatedChat.workspaceId },
{
groups: { workspace: updatedChat.workspaceId },
}
)
}
if (pinned !== undefined) {
captureServerEvent(
userId,
pinned ? 'task_pinned' : 'task_unpinned',
{ workspace_id: updatedChat.workspaceId },
{
groups: { workspace: updatedChat.workspaceId },
}
)
}
}
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error updating mothership chat:', error)
return createInternalServerErrorResponse('Failed to update chat')
}
}
)
export const DELETE = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => {
try {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
if (!isAuthenticated || !userId) {
return createUnauthorizedResponse()
}
const parsed = await parseRequest(deleteMothershipChatContract, request, context)
if (!parsed.success) return parsed.response
const { chatId } = parsed.data.params
const chat = await getAccessibleCopilotChatAuth(chatId, userId)
if (!chat || chat.type !== 'mothership') {
return NextResponse.json({ success: true })
}
const [deletedChat] = await db
.delete(copilotChats)
.where(
and(
eq(copilotChats.id, chatId),
eq(copilotChats.userId, userId),
eq(copilotChats.type, 'mothership')
)
)
.returning({
workspaceId: copilotChats.workspaceId,
})
if (!deletedChat) {
return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 })
}
if (deletedChat.workspaceId) {
chatPubSub?.publishStatusChanged({
workspaceId: deletedChat.workspaceId,
chatId,
type: 'deleted',
})
captureServerEvent(
userId,
'task_deleted',
{ workspace_id: deletedChat.workspaceId },
{
groups: { workspace: deletedChat.workspaceId },
}
)
}
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error deleting mothership chat:', error)
return createInternalServerErrorResponse('Failed to delete chat')
}
}
)