Skip to content

Commit 977061c

Browse files
committed
Merge branch 'dev' of github.com:simstudioai/sim into dev
2 parents 8c9903f + 833f132 commit 977061c

File tree

23 files changed

+1926
-1331
lines changed

23 files changed

+1926
-1331
lines changed

apps/sim/app/api/copilot/chat/route.ts

Lines changed: 1 addition & 465 deletions
Large diffs are not rendered by default.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { db } from '@sim/db'
2+
import { copilotChats } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { and, eq, sql } from 'drizzle-orm'
5+
import { type NextRequest, NextResponse } from 'next/server'
6+
import { z } from 'zod'
7+
import { getSession } from '@/lib/auth'
8+
import { normalizeMessage, type PersistedMessage } from '@/lib/copilot/chat/persisted-message'
9+
import { taskPubSub } from '@/lib/copilot/tasks'
10+
11+
const logger = createLogger('CopilotChatStopAPI')
12+
13+
const StoredToolCallSchema = z
14+
.object({
15+
id: z.string().optional(),
16+
name: z.string().optional(),
17+
state: z.string().optional(),
18+
params: z.record(z.unknown()).optional(),
19+
result: z
20+
.object({
21+
success: z.boolean(),
22+
output: z.unknown().optional(),
23+
error: z.string().optional(),
24+
})
25+
.optional(),
26+
display: z
27+
.object({
28+
text: z.string().optional(),
29+
title: z.string().optional(),
30+
phaseLabel: z.string().optional(),
31+
})
32+
.optional(),
33+
calledBy: z.string().optional(),
34+
durationMs: z.number().optional(),
35+
error: z.string().optional(),
36+
})
37+
.nullable()
38+
39+
const ContentBlockSchema = z.object({
40+
type: z.string(),
41+
lane: z.enum(['main', 'subagent']).optional(),
42+
content: z.string().optional(),
43+
channel: z.enum(['assistant', 'thinking']).optional(),
44+
phase: z.enum(['call', 'args_delta', 'result']).optional(),
45+
kind: z.enum(['subagent', 'structured_result', 'subagent_result']).optional(),
46+
lifecycle: z.enum(['start', 'end']).optional(),
47+
status: z.enum(['complete', 'error', 'cancelled']).optional(),
48+
toolCall: StoredToolCallSchema.optional(),
49+
})
50+
51+
const StopSchema = z.object({
52+
chatId: z.string(),
53+
streamId: z.string(),
54+
content: z.string(),
55+
contentBlocks: z.array(ContentBlockSchema).optional(),
56+
})
57+
58+
/**
59+
* POST /api/copilot/chat/stop
60+
* Persists partial assistant content when the user stops a stream mid-response.
61+
* Clears conversationId so the server-side onComplete won't duplicate the message.
62+
* The chat stream lock is intentionally left alone here; it is released only once
63+
* the aborted server stream actually unwinds.
64+
*/
65+
export async function POST(req: NextRequest) {
66+
try {
67+
const session = await getSession()
68+
if (!session?.user?.id) {
69+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
70+
}
71+
72+
const { chatId, streamId, content, contentBlocks } = StopSchema.parse(await req.json())
73+
74+
const [row] = await db
75+
.select({
76+
workspaceId: copilotChats.workspaceId,
77+
messages: copilotChats.messages,
78+
})
79+
.from(copilotChats)
80+
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id)))
81+
.limit(1)
82+
83+
if (!row) {
84+
return NextResponse.json({ success: true })
85+
}
86+
87+
const messages: Record<string, unknown>[] = Array.isArray(row.messages) ? row.messages : []
88+
const userIdx = messages.findIndex((message) => message.id === streamId)
89+
const alreadyHasResponse =
90+
userIdx >= 0 &&
91+
userIdx + 1 < messages.length &&
92+
(messages[userIdx + 1] as Record<string, unknown>)?.role === 'assistant'
93+
const canAppendAssistant =
94+
userIdx >= 0 && userIdx === messages.length - 1 && !alreadyHasResponse
95+
96+
const setClause: Record<string, unknown> = {
97+
conversationId: sql`CASE WHEN ${copilotChats.conversationId} = ${streamId} THEN NULL ELSE ${copilotChats.conversationId} END`,
98+
updatedAt: new Date(),
99+
}
100+
101+
const hasContent = content.trim().length > 0
102+
const hasBlocks = Array.isArray(contentBlocks) && contentBlocks.length > 0
103+
104+
if ((hasContent || hasBlocks) && canAppendAssistant) {
105+
const normalized = normalizeMessage({
106+
id: crypto.randomUUID(),
107+
role: 'assistant',
108+
content,
109+
timestamp: new Date().toISOString(),
110+
...(hasBlocks ? { contentBlocks } : {}),
111+
})
112+
const assistantMessage: PersistedMessage = normalized
113+
setClause.messages = sql`${copilotChats.messages} || ${JSON.stringify([assistantMessage])}::jsonb`
114+
}
115+
116+
const [updated] = await db
117+
.update(copilotChats)
118+
.set(setClause)
119+
.where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id)))
120+
.returning({ workspaceId: copilotChats.workspaceId })
121+
122+
if (updated?.workspaceId) {
123+
taskPubSub?.publishStatusChanged({
124+
workspaceId: updated.workspaceId,
125+
chatId,
126+
type: 'completed',
127+
})
128+
}
129+
130+
return NextResponse.json({ success: true })
131+
} catch (error) {
132+
if (error instanceof z.ZodError) {
133+
return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
134+
}
135+
logger.error('Error stopping chat stream:', error)
136+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
137+
}
138+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { POST } from '@/app/api/copilot/chat/abort/route'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { DELETE, PATCH, POST } from '@/app/api/copilot/chat/resources/route'

0 commit comments

Comments
 (0)