mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(copilot): use different chats for different workflows (#4324)
* fix(copilot): use different chats for different workflows * remove use effect * trim comments
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { History, Plus, Square } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
@@ -59,6 +60,12 @@ import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]
|
||||
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
|
||||
import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
||||
import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { useCopilotChatSelection } from '@/hooks/queries/copilot-chat-selection'
|
||||
import {
|
||||
type CopilotChatListItem,
|
||||
copilotChatsKeys,
|
||||
useCopilotChats,
|
||||
} from '@/hooks/queries/copilot-chats'
|
||||
import { useDuplicateWorkflowMutation, useWorkflowMap } from '@/hooks/queries/workflows'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
@@ -77,6 +84,7 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('Panel')
|
||||
const EMPTY_COPILOT_CHATS: readonly CopilotChatListItem[] = []
|
||||
/**
|
||||
* Panel component with resizable width and tab navigation that persists across page refreshes.
|
||||
*
|
||||
@@ -222,63 +230,59 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
const currentWorkflow = activeWorkflowId ? workflows[activeWorkflowId] : null
|
||||
const { isSnapshotView } = useCurrentWorkflow()
|
||||
|
||||
const [copilotChatId, setCopilotChatId] = useState<string | undefined>(undefined)
|
||||
const [copilotChatTitle, setCopilotChatTitle] = useState<string | null>(null)
|
||||
const [copilotChatList, setCopilotChatList] = useState<
|
||||
{ id: string; title: string | null; updatedAt: string; activeStreamId: string | null }[]
|
||||
>([])
|
||||
const { chatId: copilotChatId, setChatId: setCopilotChatId } = useCopilotChatSelection(
|
||||
activeWorkflowId ?? undefined
|
||||
)
|
||||
|
||||
const { data: copilotChatList = EMPTY_COPILOT_CHATS } = useCopilotChats(
|
||||
activeWorkflowId ?? undefined
|
||||
)
|
||||
const [isCopilotHistoryOpen, setIsCopilotHistoryOpen] = useState(false)
|
||||
|
||||
const copilotChatIdRef = useRef(copilotChatId)
|
||||
copilotChatIdRef.current = copilotChatId
|
||||
const copilotInitialLoadDoneRef = useRef(false)
|
||||
const copilotChatTitle = useMemo(
|
||||
() =>
|
||||
copilotChatId ? (copilotChatList.find((c) => c.id === copilotChatId)?.title ?? null) : null,
|
||||
[copilotChatId, copilotChatList]
|
||||
)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const loadCopilotChats = useCallback(() => {
|
||||
if (!activeWorkflowId) return
|
||||
fetch('/api/copilot/chats')
|
||||
.then((res) => (res.ok ? res.json() : { chats: [] }))
|
||||
.then((data) => {
|
||||
const allChats = Array.isArray(data?.chats) ? data.chats : []
|
||||
const filtered = allChats.filter(
|
||||
(c: { workflowId?: string }) => c.workflowId === activeWorkflowId
|
||||
) as Array<{
|
||||
id: string
|
||||
title: string | null
|
||||
updatedAt: string
|
||||
activeStreamId: string | null
|
||||
}>
|
||||
setCopilotChatList(filtered)
|
||||
|
||||
const currentId = copilotChatIdRef.current
|
||||
if (currentId) {
|
||||
const match = filtered.find((c: { id: string }) => c.id === currentId)
|
||||
if (match?.title) setCopilotChatTitle(match.title)
|
||||
}
|
||||
|
||||
if (!copilotInitialLoadDoneRef.current && !currentId && filtered.length > 0) {
|
||||
copilotInitialLoadDoneRef.current = true
|
||||
setCopilotChatId(filtered[0].id)
|
||||
setCopilotChatTitle(filtered[0].title)
|
||||
}
|
||||
copilotInitialLoadDoneRef.current = true
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [activeWorkflowId])
|
||||
queryClient.invalidateQueries({ queryKey: copilotChatsKeys.list(activeWorkflowId) })
|
||||
}, [activeWorkflowId, queryClient])
|
||||
|
||||
// Auto-select most recent on first list arrival per workflow, and drop a
|
||||
// selection that no longer matches anything in the current list (e.g. the
|
||||
// chat was deleted in another tab).
|
||||
const autoSelectAttemptedForRef = useRef<Set<string>>(new Set())
|
||||
useEffect(() => {
|
||||
copilotInitialLoadDoneRef.current = false
|
||||
loadCopilotChats()
|
||||
}, [loadCopilotChats])
|
||||
if (!activeWorkflowId) return
|
||||
|
||||
if (copilotChatId && !copilotChatList.find((c) => c.id === copilotChatId)) {
|
||||
setCopilotChatId(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (copilotChatId) return
|
||||
if (autoSelectAttemptedForRef.current.has(activeWorkflowId)) return
|
||||
autoSelectAttemptedForRef.current.add(activeWorkflowId)
|
||||
|
||||
if (copilotChatList.length > 0) {
|
||||
setCopilotChatId(copilotChatList[0].id)
|
||||
}
|
||||
}, [copilotChatList, copilotChatId, activeWorkflowId, setCopilotChatId])
|
||||
|
||||
useEffect(() => {
|
||||
posthogRef.current = posthog
|
||||
}, [posthog])
|
||||
|
||||
const handleCopilotSelectChat = useCallback((chat: { id: string; title: string | null }) => {
|
||||
setCopilotChatId(chat.id)
|
||||
setCopilotChatTitle(chat.title)
|
||||
setIsCopilotHistoryOpen(false)
|
||||
}, [])
|
||||
const handleCopilotSelectChat = useCallback(
|
||||
(chat: { id: string; title: string | null }) => {
|
||||
setCopilotChatId(chat.id)
|
||||
setIsCopilotHistoryOpen(false)
|
||||
},
|
||||
[setCopilotChatId]
|
||||
)
|
||||
|
||||
const handleCopilotDeleteChat = useCallback(
|
||||
(chatId: string) => {
|
||||
@@ -290,13 +294,12 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
.then(() => {
|
||||
if (copilotChatId === chatId) {
|
||||
setCopilotChatId(undefined)
|
||||
setCopilotChatTitle(null)
|
||||
}
|
||||
loadCopilotChats()
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
[copilotChatId, loadCopilotChats]
|
||||
[copilotChatId, loadCopilotChats, setCopilotChatId]
|
||||
)
|
||||
|
||||
const handleCopilotToolResult = useCallback(
|
||||
@@ -361,14 +364,13 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
.then((data: { id?: string }) => {
|
||||
if (data?.id) {
|
||||
setCopilotChatId(data.id)
|
||||
setCopilotChatTitle(null)
|
||||
loadCopilotChats()
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('Failed to create copilot chat', err)
|
||||
})
|
||||
}, [activeWorkflowId, workspaceId, loadCopilotChats])
|
||||
}, [activeWorkflowId, workspaceId, loadCopilotChats, setCopilotChatId])
|
||||
|
||||
const prevResolvedRef = useRef<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
@@ -383,7 +385,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
|
||||
} else {
|
||||
prevResolvedRef.current = copilotResolvedChatId
|
||||
}
|
||||
}, [copilotResolvedChatId, copilotChatId, loadCopilotChats])
|
||||
}, [copilotResolvedChatId, copilotChatId, loadCopilotChats, setCopilotChatId])
|
||||
|
||||
const wasCopilotSendingRef = useRef(false)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from 'react'
|
||||
import { skipToken, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
export const copilotChatSelectionKeys = {
|
||||
all: ['copilot-chat-selection'] as const,
|
||||
workflows: () => [...copilotChatSelectionKeys.all, 'workflow'] as const,
|
||||
workflow: (workflowId?: string) =>
|
||||
[...copilotChatSelectionKeys.workflows(), workflowId ?? ''] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive per-workflow copilot chat selection. Values are written via the
|
||||
* returned setter; queryFn is `skipToken` so the cache only ever holds
|
||||
* what setQueryData puts there.
|
||||
*/
|
||||
export function useCopilotChatSelection(workflowId?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: chatId } = useQuery<string | null>({
|
||||
queryKey: copilotChatSelectionKeys.workflow(workflowId),
|
||||
queryFn: skipToken,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
initialData: null,
|
||||
})
|
||||
|
||||
const setChatId = useCallback(
|
||||
(next: string | undefined) => {
|
||||
if (!workflowId) return
|
||||
queryClient.setQueryData<string | null>(
|
||||
copilotChatSelectionKeys.workflow(workflowId),
|
||||
next ?? null
|
||||
)
|
||||
},
|
||||
[workflowId, queryClient]
|
||||
)
|
||||
|
||||
return { chatId: chatId ?? undefined, setChatId }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||
|
||||
export interface CopilotChatListItem {
|
||||
id: string
|
||||
title: string | null
|
||||
workflowId?: string
|
||||
updatedAt: string
|
||||
activeStreamId: string | null
|
||||
}
|
||||
|
||||
export const copilotChatsKeys = {
|
||||
all: ['copilot-chats'] as const,
|
||||
lists: () => [...copilotChatsKeys.all, 'list'] as const,
|
||||
list: (workflowId?: string) => [...copilotChatsKeys.lists(), workflowId ?? ''] as const,
|
||||
}
|
||||
|
||||
async function fetchCopilotChats(
|
||||
workflowId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<CopilotChatListItem[]> {
|
||||
const res = await fetch('/api/copilot/chats', { signal })
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
const all = Array.isArray(data?.chats) ? (data.chats as CopilotChatListItem[]) : []
|
||||
return all.filter((c) => c.workflowId === workflowId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Workflow-scoped copilot chat list. Each workflowId has its own cache entry
|
||||
* so switching workflows reads the right list synchronously instead of
|
||||
* showing the previous workflow's chats during the refetch.
|
||||
*/
|
||||
export function useCopilotChats(workflowId?: string) {
|
||||
return useQuery<CopilotChatListItem[]>({
|
||||
queryKey: copilotChatsKeys.list(workflowId),
|
||||
queryFn: workflowId ? ({ signal }) => fetchCopilotChats(workflowId, signal) : skipToken,
|
||||
staleTime: 30 * 1000,
|
||||
})
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export async function getAccessibleCopilotChat(chatId: string, userId: string) {
|
||||
.limit(1)
|
||||
|
||||
if (!chat) {
|
||||
logger.warn('Copilot chat not found or not owned by user', { chatId, userId })
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -38,11 +39,21 @@ export async function getAccessibleCopilotChat(chatId: string, userId: string) {
|
||||
action: 'read',
|
||||
})
|
||||
if (!authorization.allowed || !authorization.workflow) {
|
||||
logger.warn('Copilot chat workflow not authorized for user', {
|
||||
chatId,
|
||||
userId,
|
||||
workflowId: chat.workflowId,
|
||||
})
|
||||
return null
|
||||
}
|
||||
} else if (chat.workspaceId) {
|
||||
const access = await checkWorkspaceAccess(chat.workspaceId, userId)
|
||||
if (!access.exists || !access.hasAccess) {
|
||||
logger.warn('Copilot chat workspace not accessible to user', {
|
||||
chatId,
|
||||
userId,
|
||||
workspaceId: chat.workspaceId,
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -74,16 +85,33 @@ export async function resolveOrCreateChat(params: {
|
||||
|
||||
if (chat) {
|
||||
if (workflowId && chat.workflowId !== workflowId) {
|
||||
logger.warn('Copilot chat workflow mismatch', {
|
||||
chatId,
|
||||
userId,
|
||||
requestWorkflowId: workflowId,
|
||||
chatWorkflowId: chat.workflowId,
|
||||
})
|
||||
return { chatId, chat: null, conversationHistory: [], isNew: false }
|
||||
}
|
||||
|
||||
if (workspaceId && chat.workspaceId !== workspaceId) {
|
||||
logger.warn('Copilot chat workspace mismatch', {
|
||||
chatId,
|
||||
userId,
|
||||
requestWorkspaceId: workspaceId,
|
||||
chatWorkspaceId: chat.workspaceId,
|
||||
})
|
||||
return { chatId, chat: null, conversationHistory: [], isNew: false }
|
||||
}
|
||||
|
||||
if (chat.workflowId) {
|
||||
const activeWorkflow = await getActiveWorkflowRecord(chat.workflowId)
|
||||
if (!activeWorkflow) {
|
||||
logger.warn('Copilot chat workflow no longer active', {
|
||||
chatId,
|
||||
userId,
|
||||
workflowId: chat.workflowId,
|
||||
})
|
||||
return { chatId, chat: null, conversationHistory: [], isNew: false }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user