mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 15:46:22 +00:00
* feat: add credit-based tracking for BrowserOS provider Send X-BrowserOS-ID header on all LLM requests through the BrowserOS gateway for per-installation credit tracking. Handle 429 CREDITS_EXHAUSTED as non-retryable. Add GET/PUT /credits endpoints to check and manage credit balance. * docs: add credits tracking UI design Design for showing credit balance in side panel chat header (color-coded badge) and a dedicated Usage & Billing settings page. Credits refresh after each completed message turn or on exhaustion error. * docs: add credits tracking UI implementation plan 8-task plan covering useCredits hook, CreditBadge component, ChatHeader integration, message completion refresh, ChatError CREDITS_EXHAUSTED handling, Usage & Billing settings page, and route/sidebar registration. * feat: add useCredits React Query hook * feat: add CreditBadge component with color thresholds * feat: show credit badge in chat header for BrowserOS provider * feat: refresh credits after chat message completion and on error * feat: handle CREDITS_EXHAUSTED error in chat * feat: add Usage & Billing settings page * feat: register usage page route and sidebar entry * fix: lint and formatting fixes for credit tracking UI * fix: separate credits exhausted from Kimi rate limit in ChatError, redesign Usage page * chore: remove PUT /credits endpoint and setCredits function * fix: extract shared credit colors, add error state to UsagePage, use dailyLimit from gateway * fix: make dailyLimit required in CreditsInfo (gateway always returns it) * feat: gate credits UI behind CREDITS_SUPPORT feature flag (server >= 0.0.78)
625 lines
20 KiB
TypeScript
625 lines
20 KiB
TypeScript
import { useChat } from '@ai-sdk/react'
|
|
import { DefaultChatTransport, type UIMessage } from 'ai'
|
|
import { compact } from 'es-toolkit/array'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import { useSearchParams } from 'react-router'
|
|
import useDeepCompareEffect from 'use-deep-compare-effect'
|
|
import type { Provider } from '@/components/chat/chatComponentTypes'
|
|
import { Capabilities, Feature } from '@/lib/browseros/capabilities'
|
|
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
|
import type { ChatAction } from '@/lib/chat-actions/types'
|
|
import {
|
|
CONVERSATION_RESET_EVENT,
|
|
GLOW_STOP_CLICKED_EVENT,
|
|
MESSAGE_DISLIKE_EVENT,
|
|
MESSAGE_LIKE_EVENT,
|
|
MESSAGE_SENT_EVENT,
|
|
PROVIDER_SELECTED_EVENT,
|
|
} from '@/lib/constants/analyticsEvents'
|
|
import {
|
|
conversationStorage,
|
|
useConversations,
|
|
} from '@/lib/conversations/conversationStorage'
|
|
import { formatConversationHistory } from '@/lib/conversations/formatConversationHistory'
|
|
import { useInvalidateCredits } from '@/lib/credits/useCredits'
|
|
import { declinedAppsStorage } from '@/lib/declined-apps/storage'
|
|
import { useGraphqlQuery } from '@/lib/graphql/useGraphqlQuery'
|
|
import { createDefaultBrowserOSProvider } from '@/lib/llm-providers/storage'
|
|
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
|
|
import { track } from '@/lib/metrics/track'
|
|
import { searchActionsStorage } from '@/lib/search-actions/searchActionsStorage'
|
|
import { selectedTextStorage } from '@/lib/selected-text/selectedTextStorage'
|
|
import { stopAgentStorage } from '@/lib/stop-agent/stop-agent-storage'
|
|
import { selectedWorkspaceStorage } from '@/lib/workspace/workspace-storage'
|
|
import type { ChatMode } from './chatTypes'
|
|
import { GetConversationWithMessagesDocument } from './graphql/chatSessionDocument'
|
|
import { useChatRefs } from './useChatRefs'
|
|
import { useNotifyActiveTab } from './useNotifyActiveTab'
|
|
import { useRemoteConversationSave } from './useRemoteConversationSave'
|
|
|
|
const getLastMessageText = (messages: UIMessage[]) => {
|
|
const lastMessage = messages[messages.length - 1]
|
|
if (!lastMessage) return ''
|
|
return lastMessage.parts
|
|
.filter((part) => part.type === 'text')
|
|
.map((part) => part.text)
|
|
.join('')
|
|
}
|
|
|
|
export const getResponseAndQueryFromMessageId = (
|
|
messages: UIMessage[],
|
|
messageId: string,
|
|
) => {
|
|
const messageIndex = messages.findIndex((each) => each.id === messageId)
|
|
const response = messages?.[messageIndex] ?? []
|
|
const query = messages?.[messageIndex - 1] ?? []
|
|
const responseText = response.parts
|
|
.filter((each) => each.type === 'text')
|
|
.map((each) => each.text)
|
|
.join('\n\n')
|
|
const queryText = query.parts
|
|
.filter((each) => each.type === 'text')
|
|
.map((each) => each.text)
|
|
.join('\n')
|
|
|
|
return {
|
|
responseText,
|
|
queryText,
|
|
}
|
|
}
|
|
|
|
export type ChatOrigin = 'sidepanel' | 'newtab'
|
|
|
|
export interface ChatSessionOptions {
|
|
origin?: ChatOrigin
|
|
/** When false, messages are queued until integrations finish syncing. */
|
|
isIntegrationsSynced?: boolean
|
|
}
|
|
|
|
const NEWTAB_SYSTEM_PROMPT = `IMPORTANT: The user is chatting from the New Tab page. When performing browser actions, ALWAYS open content in a NEW TAB rather than navigating the current tab. The user's new tab page should remain accessible.`
|
|
|
|
export const useChatSession = (options?: ChatSessionOptions) => {
|
|
const {
|
|
selectedLlmProviderRef,
|
|
enabledMcpServersRef,
|
|
enabledCustomServersRef,
|
|
personalizationRef,
|
|
selectedLlmProvider,
|
|
isLoadingProviders,
|
|
} = useChatRefs()
|
|
const invalidateCredits = useInvalidateCredits()
|
|
|
|
const { providers: llmProviders, setDefaultProvider } = useLlmProviders()
|
|
|
|
const {
|
|
baseUrl: agentServerUrl,
|
|
isLoading: isLoadingAgentUrl,
|
|
error: agentUrlError,
|
|
} = useAgentServerUrl()
|
|
|
|
const { saveConversation: saveLocalConversation } = useConversations()
|
|
const {
|
|
isLoggedIn,
|
|
saveConversation: saveRemoteConversation,
|
|
resetConversation: resetRemoteConversation,
|
|
markMessagesAsSaved,
|
|
} = useRemoteConversationSave()
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
const conversationIdParam = searchParams.get('conversationId')
|
|
|
|
const agentUrlRef = useRef(agentServerUrl)
|
|
|
|
useEffect(() => {
|
|
agentUrlRef.current = agentServerUrl
|
|
}, [agentServerUrl])
|
|
|
|
const providers: Provider[] = llmProviders.map((p) => ({
|
|
id: p.id,
|
|
name: p.name,
|
|
type: p.type,
|
|
}))
|
|
|
|
const [mode, setMode] = useState<ChatMode>('agent')
|
|
const [textToAction, setTextToAction] = useState<Map<string, ChatAction>>(
|
|
new Map(),
|
|
)
|
|
const [liked, setLiked] = useState<Record<string, boolean>>({})
|
|
const [disliked, setDisliked] = useState<Record<string, boolean>>({})
|
|
const [conversationId, setConversationId] = useState(crypto.randomUUID())
|
|
const conversationIdRef = useRef(conversationId)
|
|
|
|
useEffect(() => {
|
|
conversationIdRef.current = conversationId
|
|
}, [conversationId])
|
|
|
|
const onClickLike = (messageId: string) => {
|
|
const { responseText, queryText } = getResponseAndQueryFromMessageId(
|
|
messages,
|
|
messageId,
|
|
)
|
|
|
|
track(MESSAGE_LIKE_EVENT, { responseText, queryText, messageId })
|
|
|
|
setLiked((prev) => ({
|
|
...prev,
|
|
[messageId]: !prev[messageId],
|
|
}))
|
|
}
|
|
|
|
const onClickDislike = (messageId: string, comment?: string) => {
|
|
const { responseText, queryText } = getResponseAndQueryFromMessageId(
|
|
messages,
|
|
messageId,
|
|
)
|
|
|
|
track(MESSAGE_DISLIKE_EVENT, {
|
|
responseText,
|
|
queryText,
|
|
messageId,
|
|
comment,
|
|
})
|
|
|
|
setDisliked((prev) => ({
|
|
...prev,
|
|
[messageId]: !prev[messageId],
|
|
}))
|
|
}
|
|
|
|
const modeRef = useRef<ChatMode>(mode)
|
|
const textToActionRef = useRef<Map<string, ChatAction>>(textToAction)
|
|
const workingDirRef = useRef<string | undefined>(undefined)
|
|
const selectionMapRef = useRef<
|
|
Record<string, { text: string; url: string; title: string }>
|
|
>({})
|
|
const pendingSelectionTabKeyRef = useRef<string | null>(null)
|
|
const messagesRef = useRef<UIMessage[]>([])
|
|
|
|
useEffect(() => {
|
|
const toRef = (
|
|
map: Record<string, { text: string; pageUrl: string; pageTitle: string }>,
|
|
) => {
|
|
const result: Record<
|
|
string,
|
|
{ text: string; url: string; title: string }
|
|
> = {}
|
|
for (const [k, v] of Object.entries(map)) {
|
|
result[k] = { text: v.text, url: v.pageUrl, title: v.pageTitle }
|
|
}
|
|
return result
|
|
}
|
|
selectedTextStorage.getValue().then((map) => {
|
|
selectionMapRef.current = toRef(map)
|
|
})
|
|
const unwatchText = selectedTextStorage.watch((map) => {
|
|
selectionMapRef.current = toRef(map)
|
|
})
|
|
return () => unwatchText()
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
selectedWorkspaceStorage.getValue().then((folder) => {
|
|
workingDirRef.current = folder?.path
|
|
})
|
|
|
|
const unwatch = selectedWorkspaceStorage.watch((folder) => {
|
|
workingDirRef.current = folder?.path
|
|
})
|
|
return () => unwatch()
|
|
}, [])
|
|
|
|
useDeepCompareEffect(() => {
|
|
modeRef.current = mode
|
|
textToActionRef.current = textToAction
|
|
}, [mode, textToAction])
|
|
|
|
const selectedProvider = selectedLlmProvider
|
|
? {
|
|
id: selectedLlmProvider.id,
|
|
name: selectedLlmProvider.name,
|
|
type:
|
|
selectedLlmProvider.id === 'browseros'
|
|
? ('browseros' as const)
|
|
: selectedLlmProvider.type,
|
|
}
|
|
: providers[0]
|
|
|
|
const {
|
|
messages,
|
|
sendMessage: baseSendMessage,
|
|
setMessages,
|
|
status,
|
|
stop,
|
|
error: chatError,
|
|
} = useChat({
|
|
transport: new DefaultChatTransport({
|
|
// Important: this chat logic is also used in apps/agent/lib/schedules/getChatServerResponse.ts for scheduled jobs. Make sure to keep them in sync for any future changes.
|
|
prepareSendMessagesRequest: async ({ messages }) => {
|
|
const activeTabsList = await chrome.tabs.query({
|
|
active: true,
|
|
currentWindow: true,
|
|
})
|
|
const activeTab = activeTabsList?.[0] ?? undefined
|
|
const activeTabSelection = activeTab?.id
|
|
? (selectionMapRef.current[String(activeTab.id)] ?? null)
|
|
: null
|
|
const message = getLastMessageText(messages)
|
|
const provider =
|
|
selectedLlmProviderRef.current ?? createDefaultBrowserOSProvider()
|
|
const currentMode = modeRef.current
|
|
const enabledMcpServers = enabledMcpServersRef.current
|
|
const customMcpServers = enabledCustomServersRef.current
|
|
|
|
const getActionForMessage = (messageText: string) => {
|
|
return textToActionRef.current.get(messageText)
|
|
}
|
|
|
|
const action = getActionForMessage(message)
|
|
|
|
const browserContext: {
|
|
windowId?: number
|
|
activeTab?: {
|
|
id?: number
|
|
url?: string
|
|
title?: string
|
|
}
|
|
selectedTabs?: {
|
|
id?: number
|
|
url?: string
|
|
title?: string
|
|
}[]
|
|
enabledMcpServers?: string[]
|
|
customMcpServers?: {
|
|
name: string
|
|
url: string
|
|
}[]
|
|
} = {}
|
|
|
|
if (activeTab) {
|
|
browserContext.windowId = activeTab.windowId
|
|
browserContext.activeTab = {
|
|
id: activeTab.id,
|
|
url: activeTab.url,
|
|
title: activeTab.title,
|
|
}
|
|
}
|
|
|
|
if (action?.tabs?.length) {
|
|
browserContext.selectedTabs = action?.tabs?.map((tab) => ({
|
|
id: tab.id,
|
|
url: tab.url,
|
|
title: tab.title,
|
|
}))
|
|
}
|
|
|
|
if (enabledMcpServers.length) {
|
|
browserContext.enabledMcpServers = compact(enabledMcpServers)
|
|
}
|
|
|
|
if (customMcpServers.length) {
|
|
browserContext.customMcpServers = customMcpServers as {
|
|
name: string
|
|
url: string
|
|
}[]
|
|
}
|
|
|
|
const declinedApps = await declinedAppsStorage.getValue()
|
|
|
|
const supportsArrayConversation = await Capabilities.supports(
|
|
Feature.PREVIOUS_CONVERSATION_ARRAY,
|
|
)
|
|
|
|
const previousMessages = messagesRef.current
|
|
const history =
|
|
previousMessages.length > 0
|
|
? formatConversationHistory(previousMessages)
|
|
: undefined
|
|
const previousConversation = history?.length
|
|
? supportsArrayConversation
|
|
? history
|
|
: history.map((m) => `${m.role}: ${m.content}`).join('\n')
|
|
: undefined
|
|
|
|
const result = {
|
|
api: `${agentUrlRef.current}/chat`,
|
|
body: {
|
|
message,
|
|
provider: provider?.type,
|
|
providerType: provider?.type,
|
|
providerName: provider?.name,
|
|
apiKey: provider?.apiKey,
|
|
baseUrl: provider?.baseUrl,
|
|
conversationId: conversationIdRef.current,
|
|
model: provider?.modelId ?? 'default',
|
|
mode: currentMode,
|
|
contextWindowSize: provider?.contextWindow,
|
|
temperature: provider?.temperature,
|
|
// Azure-specific
|
|
resourceName: provider?.resourceName,
|
|
// Bedrock-specific
|
|
accessKeyId: provider?.accessKeyId,
|
|
secretAccessKey: provider?.secretAccessKey,
|
|
region: provider?.region,
|
|
sessionToken: provider?.sessionToken,
|
|
// ChatGPT Pro (Codex)
|
|
reasoningEffort: provider?.reasoningEffort,
|
|
reasoningSummary: provider?.reasoningSummary,
|
|
browserContext,
|
|
userSystemPrompt:
|
|
options?.origin === 'newtab'
|
|
? [personalizationRef.current, NEWTAB_SYSTEM_PROMPT]
|
|
.filter(Boolean)
|
|
.join('\n\n')
|
|
: personalizationRef.current,
|
|
userWorkingDir: workingDirRef.current,
|
|
supportsImages: provider?.supportsImages,
|
|
previousConversation,
|
|
declinedApps: declinedApps.length > 0 ? declinedApps : undefined,
|
|
selectedText: activeTabSelection?.text,
|
|
selectedTextSource: activeTabSelection
|
|
? {
|
|
url: activeTabSelection.url,
|
|
title: activeTabSelection.title,
|
|
}
|
|
: undefined,
|
|
},
|
|
}
|
|
|
|
// Track which tab's selection was sent so we can clear it on success
|
|
pendingSelectionTabKeyRef.current =
|
|
activeTabSelection && activeTab?.id ? String(activeTab.id) : null
|
|
|
|
return result
|
|
},
|
|
}),
|
|
})
|
|
|
|
// Remove messages with empty parts (e.g. interrupted assistant responses)
|
|
// to prevent AI SDK validation errors on subsequent sends
|
|
useEffect(() => {
|
|
if (status === 'streaming') return
|
|
if (messages.some((m) => !m.parts?.length)) {
|
|
setMessages(messages.filter((m) => m.parts?.length > 0))
|
|
}
|
|
}, [messages, status, setMessages])
|
|
|
|
useNotifyActiveTab({
|
|
messages,
|
|
status,
|
|
conversationId: conversationIdRef.current,
|
|
})
|
|
|
|
const {
|
|
data: remoteConversationData,
|
|
isFetched: isRemoteConversationFetched,
|
|
} = useGraphqlQuery(
|
|
GetConversationWithMessagesDocument,
|
|
{ conversationId: conversationIdParam ?? '' },
|
|
{
|
|
enabled: !!conversationIdParam && isLoggedIn,
|
|
},
|
|
)
|
|
|
|
const [restoredConversationId, setRestoredConversationId] = useState<
|
|
string | null
|
|
>(null)
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: restore should only run when query data arrives or conversationIdParam changes
|
|
useEffect(() => {
|
|
if (!conversationIdParam) return
|
|
if (restoredConversationId === conversationIdParam) return
|
|
|
|
if (isLoggedIn) {
|
|
if (!isRemoteConversationFetched) return
|
|
|
|
if (remoteConversationData?.conversation) {
|
|
const restoredMessages =
|
|
remoteConversationData.conversation.conversationMessages.nodes
|
|
.filter((node): node is NonNullable<typeof node> => node !== null)
|
|
.map((node) => node.message as UIMessage)
|
|
|
|
setConversationId(
|
|
conversationIdParam as ReturnType<typeof crypto.randomUUID>,
|
|
)
|
|
setMessages(restoredMessages)
|
|
markMessagesAsSaved(conversationIdParam, restoredMessages)
|
|
}
|
|
setRestoredConversationId(conversationIdParam)
|
|
setSearchParams({}, { replace: true })
|
|
} else {
|
|
const restoreLocal = async () => {
|
|
const conversations = await conversationStorage.getValue()
|
|
const conversation = conversations?.find(
|
|
(c) => c.id === conversationIdParam,
|
|
)
|
|
|
|
if (conversation) {
|
|
setConversationId(
|
|
conversation.id as ReturnType<typeof crypto.randomUUID>,
|
|
)
|
|
setMessages(conversation.messages)
|
|
}
|
|
setRestoredConversationId(conversationIdParam)
|
|
setSearchParams({}, { replace: true })
|
|
}
|
|
restoreLocal()
|
|
}
|
|
}, [conversationIdParam, remoteConversationData, isLoggedIn])
|
|
|
|
// Keep messagesRef in sync on every change (cheap ref assignment)
|
|
useEffect(() => {
|
|
messagesRef.current = messages
|
|
}, [messages])
|
|
|
|
// Save conversation only after streaming completes — not on every token
|
|
const previousStatusRef = useRef(status)
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: only save when streaming finishes
|
|
useEffect(() => {
|
|
const wasStreaming =
|
|
previousStatusRef.current === 'streaming' ||
|
|
previousStatusRef.current === 'submitted'
|
|
const justFinished = wasStreaming && status === 'ready'
|
|
previousStatusRef.current = status
|
|
|
|
if (!justFinished) return
|
|
|
|
// Clear the selected text that was sent with this request
|
|
const tabKey = pendingSelectionTabKeyRef.current
|
|
if (tabKey) {
|
|
pendingSelectionTabKeyRef.current = null
|
|
delete selectionMapRef.current[tabKey]
|
|
selectedTextStorage.getValue().then((map) => {
|
|
if (map[tabKey]) {
|
|
const { [tabKey]: _, ...rest } = map
|
|
selectedTextStorage.setValue(rest)
|
|
}
|
|
})
|
|
}
|
|
|
|
const messagesToSave = messages.filter((m) => m.parts?.length > 0)
|
|
if (messagesToSave.length === 0) return
|
|
|
|
if (isLoggedIn) {
|
|
saveRemoteConversation(conversationIdRef.current, messagesToSave)
|
|
} else {
|
|
saveLocalConversation(conversationIdRef.current, messagesToSave)
|
|
}
|
|
|
|
invalidateCredits()
|
|
}, [status])
|
|
|
|
useEffect(() => {
|
|
if (chatError) invalidateCredits()
|
|
}, [chatError, invalidateCredits])
|
|
|
|
const isIntegrationsSynced = options?.isIntegrationsSynced ?? true
|
|
const isIntegrationsSyncedRef = useRef(isIntegrationsSynced)
|
|
const pendingMessageRef = useRef<{
|
|
text: string
|
|
action?: ChatAction
|
|
} | null>(null)
|
|
|
|
useEffect(() => {
|
|
isIntegrationsSyncedRef.current = isIntegrationsSynced
|
|
}, [isIntegrationsSynced])
|
|
|
|
// Flush pending message when integrations sync completes
|
|
useEffect(() => {
|
|
if (isIntegrationsSynced && pendingMessageRef.current) {
|
|
const pending = pendingMessageRef.current
|
|
pendingMessageRef.current = null
|
|
if (pending.action) {
|
|
setTextToAction((prev) => {
|
|
const next = new Map(prev)
|
|
// biome-ignore lint/style/noNonNullAssertion: guarded by if (pending.action) above
|
|
next.set(pending.text, pending.action!)
|
|
return next
|
|
})
|
|
}
|
|
baseSendMessage({ text: pending.text })
|
|
}
|
|
}, [isIntegrationsSynced, baseSendMessage])
|
|
|
|
const sendMessage = (params: { text: string; action?: ChatAction }) => {
|
|
track(MESSAGE_SENT_EVENT, {
|
|
mode,
|
|
provider_type: selectedLlmProvider?.type,
|
|
model: selectedLlmProvider?.modelId,
|
|
})
|
|
|
|
if (!isIntegrationsSyncedRef.current) {
|
|
// Queue the message — will be sent when sync completes
|
|
pendingMessageRef.current = params
|
|
return
|
|
}
|
|
|
|
if (params.action) {
|
|
const action = params.action
|
|
setTextToAction((prev) => {
|
|
const next = new Map(prev)
|
|
next.set(params.text, action)
|
|
return next
|
|
})
|
|
}
|
|
baseSendMessage({ text: params.text })
|
|
}
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: only need to run this once
|
|
useEffect(() => {
|
|
const unwatch = searchActionsStorage.watch((storageAction) => {
|
|
if (storageAction) {
|
|
setMode(storageAction.mode)
|
|
sendMessage({ text: storageAction.query, action: storageAction.action })
|
|
}
|
|
})
|
|
return () => unwatch()
|
|
}, [])
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: only need to run this once
|
|
useEffect(() => {
|
|
const unwatch = stopAgentStorage.watch((signal) => {
|
|
if (signal && signal.conversationId === conversationIdRef.current) {
|
|
stop()
|
|
track(GLOW_STOP_CLICKED_EVENT)
|
|
stopAgentStorage.setValue(null)
|
|
}
|
|
})
|
|
return () => unwatch()
|
|
}, [])
|
|
|
|
const handleSelectProvider = (provider: Provider) => {
|
|
track(PROVIDER_SELECTED_EVENT, {
|
|
provider_id: provider.id,
|
|
provider_type: provider.type,
|
|
})
|
|
setDefaultProvider(provider.id)
|
|
}
|
|
|
|
const getActionForMessage = (message: UIMessage) => {
|
|
if (message.role !== 'user') return undefined
|
|
const text = message.parts
|
|
.filter((part) => part.type === 'text')
|
|
.map((part) => part.text)
|
|
.join('')
|
|
return textToAction.get(text)
|
|
}
|
|
|
|
const resetConversation = () => {
|
|
track(CONVERSATION_RESET_EVENT, { message_count: messages.length })
|
|
stop()
|
|
setConversationId(crypto.randomUUID())
|
|
setMessages([])
|
|
setTextToAction(new Map())
|
|
setLiked({})
|
|
setDisliked({})
|
|
setRestoredConversationId(null)
|
|
resetRemoteConversation()
|
|
}
|
|
|
|
const isRestoringConversation =
|
|
!!conversationIdParam && restoredConversationId !== conversationIdParam
|
|
|
|
return {
|
|
mode,
|
|
setMode,
|
|
messages,
|
|
sendMessage,
|
|
status,
|
|
stop,
|
|
providers,
|
|
selectedProvider,
|
|
isLoading: isLoadingProviders || isLoadingAgentUrl,
|
|
isSyncing: !isIntegrationsSynced,
|
|
isRestoringConversation,
|
|
agentUrlError,
|
|
chatError,
|
|
handleSelectProvider,
|
|
getActionForMessage,
|
|
resetConversation,
|
|
liked,
|
|
onClickLike,
|
|
disliked,
|
|
onClickDislike,
|
|
conversationId,
|
|
}
|
|
}
|