chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
import { db } from '@sim/db'
|
||||
import { copilotChats } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { forkMothershipChatContract } from '@/lib/api/contracts/mothership-chats'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { loadCopilotChatMessages } from '@/lib/copilot/chat/lifecycle'
|
||||
import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store'
|
||||
import { chatPubSub } from '@/lib/copilot/chat-status'
|
||||
import { fetchGo } from '@/lib/copilot/request/go/fetch'
|
||||
import {
|
||||
authenticateCopilotRequestSessionOnly,
|
||||
createBadRequestResponse,
|
||||
createForbiddenResponse,
|
||||
createInternalServerErrorResponse,
|
||||
createNotFoundResponse,
|
||||
createUnauthorizedResponse,
|
||||
} from '@/lib/copilot/request/http'
|
||||
import type { MothershipResource } from '@/lib/copilot/resources/types'
|
||||
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import {
|
||||
assertActiveWorkspaceAccess,
|
||||
isWorkspaceAccessDeniedError,
|
||||
} from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('ForkChatAPI')
|
||||
|
||||
/**
|
||||
* POST /api/mothership/chats/[chatId]/fork
|
||||
* Creates a new chat branched from the given chat, keeping messages up to and
|
||||
* including the specified message. Resources and copilot-side state are copied.
|
||||
*/
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => {
|
||||
try {
|
||||
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
|
||||
if (!isAuthenticated || !userId) {
|
||||
return createUnauthorizedResponse()
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(forkMothershipChatContract, request, context, {
|
||||
validationErrorResponse: () => createBadRequestResponse('upToMessageId is required'),
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { chatId } = parsed.data.params
|
||||
const { upToMessageId } = parsed.data.body
|
||||
|
||||
const [parent] = await db
|
||||
.select({
|
||||
id: copilotChats.id,
|
||||
userId: copilotChats.userId,
|
||||
type: copilotChats.type,
|
||||
workspaceId: copilotChats.workspaceId,
|
||||
title: copilotChats.title,
|
||||
model: copilotChats.model,
|
||||
resources: copilotChats.resources,
|
||||
previewYaml: copilotChats.previewYaml,
|
||||
planArtifact: copilotChats.planArtifact,
|
||||
config: copilotChats.config,
|
||||
})
|
||||
.from(copilotChats)
|
||||
.where(eq(copilotChats.id, chatId))
|
||||
.limit(1)
|
||||
|
||||
if (!parent || parent.userId !== userId || parent.type !== 'mothership') {
|
||||
return createNotFoundResponse('Chat not found')
|
||||
}
|
||||
|
||||
if (parent.workspaceId) {
|
||||
await assertActiveWorkspaceAccess(parent.workspaceId, userId)
|
||||
}
|
||||
|
||||
const messages = await loadCopilotChatMessages(chatId)
|
||||
const forkIdx = messages.findIndex((m) => m.id === upToMessageId)
|
||||
if (forkIdx < 0) {
|
||||
return createBadRequestResponse('Message not found in chat')
|
||||
}
|
||||
const forkedMessages = messages.slice(0, forkIdx + 1)
|
||||
|
||||
// Resources are stored as a jsonb array on the chat row — copy them directly.
|
||||
const parentResources = Array.isArray(parent.resources)
|
||||
? (parent.resources as MothershipResource[])
|
||||
: []
|
||||
|
||||
const newId = generateId()
|
||||
const baseTitle = (parent.title ?? 'New chat').replace(/^Fork \| /, '')
|
||||
const title = `Fork | ${baseTitle}`
|
||||
const now = new Date()
|
||||
|
||||
const newChat = await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.insert(copilotChats)
|
||||
.values({
|
||||
id: newId,
|
||||
userId,
|
||||
workspaceId: parent.workspaceId,
|
||||
type: parent.type,
|
||||
title,
|
||||
model: parent.model,
|
||||
resources: parentResources,
|
||||
previewYaml: parent.previewYaml,
|
||||
planArtifact: parent.planArtifact,
|
||||
config: parent.config,
|
||||
conversationId: null,
|
||||
updatedAt: now,
|
||||
lastSeenAt: now,
|
||||
})
|
||||
.returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId })
|
||||
|
||||
if (!row) return null
|
||||
|
||||
await appendCopilotChatMessages(newId, forkedMessages, { chatModel: parent.model }, tx)
|
||||
return row
|
||||
})
|
||||
|
||||
if (!newChat) {
|
||||
return createInternalServerErrorResponse('Failed to create forked chat')
|
||||
}
|
||||
|
||||
// Clone copilot-service conversation state (messages, active_messages, memory files).
|
||||
// Best-effort: if the copilot service doesn't have a row for the source chat yet, skip.
|
||||
try {
|
||||
const copilotHeaders: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (env.COPILOT_API_KEY) {
|
||||
copilotHeaders['x-api-key'] = env.COPILOT_API_KEY
|
||||
}
|
||||
Object.assign(copilotHeaders, getMothershipSourceEnvHeaders())
|
||||
const mothershipBaseURL = await getMothershipBaseURL({ userId })
|
||||
const copilotRes = await fetchGo(`${mothershipBaseURL}/api/chats/fork`, {
|
||||
method: 'POST',
|
||||
headers: copilotHeaders,
|
||||
body: JSON.stringify({
|
||||
sourceChatId: chatId,
|
||||
newChatId: newId,
|
||||
upToMessageId,
|
||||
userId,
|
||||
}),
|
||||
spanName: 'sim → go /api/chats/fork',
|
||||
operation: 'fork_chat',
|
||||
})
|
||||
if (!copilotRes.ok) {
|
||||
const text = await copilotRes.text().catch(() => '')
|
||||
logger.warn('Copilot fork returned non-OK', { status: copilotRes.status, body: text })
|
||||
}
|
||||
} catch (err) {
|
||||
// The copilot service may not have a row for this chat if no messages
|
||||
// have been sent yet, or if it's unreachable. Log and continue.
|
||||
logger.warn('Failed to fork copilot-service conversation, skipping', { err })
|
||||
}
|
||||
|
||||
if (newChat.workspaceId) {
|
||||
chatPubSub?.publishStatusChanged({
|
||||
workspaceId: newChat.workspaceId,
|
||||
chatId: newId,
|
||||
type: 'created',
|
||||
})
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'task_forked',
|
||||
{ workspace_id: parent.workspaceId ?? '', source_chat_id: chatId },
|
||||
{ groups: { workspace: parent.workspaceId ?? '' } }
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, id: newId })
|
||||
} catch (error) {
|
||||
if (isWorkspaceAccessDeniedError(error)) {
|
||||
return createForbiddenResponse('Workspace access denied')
|
||||
}
|
||||
logger.error('Error forking chat:', error)
|
||||
return createInternalServerErrorResponse('Failed to fork chat')
|
||||
}
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user