Merge branch 'feat/agents-new' into refactor/agent-assistant-unified

This commit is contained in:
suyao 2025-09-22 19:45:23 +08:00
commit 7e369bef00
No known key found for this signature in database
25 changed files with 309 additions and 77 deletions

View File

@ -15,6 +15,11 @@ import { modelsRoutes } from './routes/models'
const logger = loggerService.withContext('ApiServer')
const app = express()
app.use(
express.json({
limit: '50mb'
})
)
// Global middleware
app.use((req, res, next) => {
@ -111,7 +116,6 @@ app.get('/', (_req, res) => {
// Provider-specific API routes with auth (must be before /v1 to avoid conflicts)
const providerRouter = express.Router({ mergeParams: true })
providerRouter.use(authMiddleware)
providerRouter.use(express.json())
// Mount provider-specific messages route
providerRouter.use('/v1/messages', messagesProviderRoutes)
app.use('/:provider', providerRouter)
@ -119,7 +123,6 @@ app.use('/:provider', providerRouter)
// API v1 routes with auth
const apiRouter = express.Router()
apiRouter.use(authMiddleware)
apiRouter.use(express.json())
// Mount routes
apiRouter.use('/chat', chatRoutes)
apiRouter.use('/mcps', mcpRoutes)

View File

@ -7,7 +7,7 @@ const logger = loggerService.withContext('ApiServerUtils')
// Cache configuration
const PROVIDERS_CACHE_KEY = 'api-server:providers'
const PROVIDERS_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
const PROVIDERS_CACHE_TTL = 1 * 60 * 1000 // 1 minutes
export async function getAvailableProviders(): Promise<Provider[]> {
try {

View File

@ -34,7 +34,7 @@ const generateMessageId = (): string => `msg_${uuidv4().replace(/-/g, '')}`
// Main transform function
export function transformSDKMessageToStreamParts(sdkMessage: SDKMessage): AgentStreamPart[] {
const chunks: AgentStreamPart[] = []
logger.debug('Transforming SDKMessage to stream parts', sdkMessage)
logger.silly('Transforming SDKMessage to stream parts', sdkMessage)
switch (sdkMessage.type) {
case 'assistant':
case 'user':
@ -105,7 +105,6 @@ function handleUserOrAssistantMessage(message: Extract<SDKMessage, { type: 'assi
}
} else if (Array.isArray(message.message.content)) {
for (const block of message.message.content) {
logger.debug('Handling user or assistant message:', { block })
switch (block.type) {
case 'text':
chunks.push(...generateTextChunks(messageId, block.text, message))
@ -127,8 +126,7 @@ function handleUserOrAssistantMessage(message: Extract<SDKMessage, { type: 'assi
})
break
case 'tool_result': {
logger.debug('Handling tool result:', { block })
logger.debug('contentblockState', { content: contentBlockState })
logger.silly('Handling tool result:', { block, content: contentBlockState })
const hasToolCall = contentBlockState.has(block.tool_use_id)
const toolCall = contentBlockState.get(block.tool_use_id) as toolCallBlock
chunks.push({
@ -161,7 +159,7 @@ function handleStreamEvent(message: Extract<SDKMessage, { type: 'stream_event' }
const chunks: AgentStreamPart[] = []
const event = message.event
const blockKey = `${message.uuid ?? message.session_id ?? 'session'}:${event.type}`
logger.debug('Handling stream event:', { event })
logger.silly('Handling stream event:', { event })
switch (event.type) {
case 'message_start':
// No specific UI chunk needed for message start in this protocol
@ -291,9 +289,6 @@ function handleStreamEvent(message: Extract<SDKMessage, { type: 'stream_event' }
// Handle system messages
function handleSystemMessage(message: Extract<SDKMessage, { type: 'system' }>): AgentStreamPart[] {
const chunks: AgentStreamPart[] = []
logger.debug('Received system message', {
subtype: message.subtype
})
switch (message.subtype) {
case 'init': {
chunks.push({

View File

@ -20,6 +20,7 @@ import { getModelLogo } from '@renderer/config/models'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useApiModels } from '@renderer/hooks/agents/useModels'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { AgentEntity, AgentSessionEntity, BaseSessionForm, CreateSessionForm, UpdateSessionForm } from '@renderer/types'
import { ChangeEvent, FormEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -86,7 +87,8 @@ export const SessionModal: React.FC<Props> = ({
const { t } = useTranslation()
const loadingRef = useRef(false)
// const { setTimeoutTimer } = useTimer()
const { createSession, updateSession } = useSessions(agentId)
const { createSession } = useSessions(agentId)
const updateSession = useUpdateSession(agentId)
// Only support claude code for now
const { models } = useApiModels({ providerType: 'anthropic' })
const { agent } = useAgent(agentId)
@ -164,7 +166,7 @@ export const SessionModal: React.FC<Props> = ({
}
if (form.accessible_paths.length === 0) {
window.toast.error(t('agent.session.accessible_paths.required'))
window.toast.error(t('agent.session.accessible_paths.error.at_least_one'))
loadingRef.current = false
return
}

View File

@ -1,4 +1,4 @@
import { CreateSessionForm, UpdateSessionForm } from '@renderer/types'
import { CreateSessionForm } from '@renderer/types'
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
@ -46,25 +46,14 @@ export const useSessions = (agentId: string) => {
const deleteSession = useCallback(
async (id: string) => {
if (!agentId) return
if (!agentId) return false
try {
await client.deleteSession(agentId, id)
mutate((prev) => prev?.filter((session) => session.id !== id))
return true
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.delete.error.failed')))
}
},
[agentId, client, mutate, t]
)
const updateSession = useCallback(
async (form: UpdateSessionForm) => {
if (!agentId) return
try {
const result = await client.updateSession(agentId, form)
mutate((prev) => prev?.map((session) => (session.id === form.id ? result : session)))
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.session.update.error.failed')))
return false
}
},
[agentId, client, mutate, t]
@ -76,7 +65,6 @@ export const useSessions = (agentId: string) => {
isLoading,
createSession,
getSession,
deleteSession,
updateSession
deleteSession
}
}

View File

@ -0,0 +1,36 @@
import { ListAgentSessionsResponse, UpdateSessionForm } from '@renderer/types'
import { formatErrorMessageWithPrefix } from '@renderer/utils/error'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { mutate } from 'swr'
import { useAgentClient } from './useAgentClient'
export const useUpdateSession = (agentId: string) => {
const { t } = useTranslation()
const client = useAgentClient()
const paths = client.getSessionPaths(agentId)
const listKey = paths.base
const updateSession = useCallback(
async (form: UpdateSessionForm) => {
const sessionId = form.id
try {
const itemKey = paths.withId(sessionId)
// may change to optimistic update
const result = await client.updateSession(agentId, form)
mutate<ListAgentSessionsResponse['data']>(
listKey,
(prev) => prev?.map((session) => (session.id === result.id ? result : session)) ?? []
)
mutate(itemKey, result)
window.toast.success(t('common.update_success'))
} catch (error) {
window.toast.error(formatErrorMessageWithPrefix(error, t('agent.update.error.failed')))
}
},
[agentId, client, listKey, paths, t]
)
return updateSession
}

View File

@ -52,7 +52,8 @@
"delete": {
"content": "Are you sure to delete this session?",
"error": {
"failed": "Failed to delete the session"
"failed": "Failed to delete the session",
"last": "At least one session must be kept"
},
"title": "Delete session"
},
@ -84,6 +85,9 @@
"error": {
"failed": "Failed to update the agent"
}
},
"warning": {
"enable_server": "Enable API Server to use agents."
}
},
"agents": {

View File

@ -52,7 +52,8 @@
"delete": {
"content": "确定要删除此会话吗?",
"error": {
"failed": "删除会话失败"
"failed": "删除会话失败",
"last": "至少需要保留一个会话"
},
"title": "删除会话"
},
@ -84,6 +85,9 @@
"error": {
"failed": "更新智能体失败"
}
},
"warning": {
"enable_server": "请启用 API 服务器以使用智能体功能"
}
},
"agents": {

View File

@ -52,7 +52,8 @@
"delete": {
"content": "您確定要刪除此工作階段嗎?",
"error": {
"failed": "無法刪除工作階段"
"failed": "無法刪除工作階段",
"last": "至少必須保留一個工作階段"
},
"title": "刪除工作階段"
},
@ -84,6 +85,9 @@
"error": {
"failed": "無法更新代理程式"
}
},
"warning": {
"enable_server": "啟用 API 伺服器以使用代理程式。"
}
},
"agents": {

View File

@ -52,7 +52,8 @@
"delete": {
"content": "Είσαι σίγουρος ότι θέλεις να διαγράψεις αυτήν τη συνεδρία;",
"error": {
"failed": "Αποτυχία διαγραφής της συνεδρίας"
"failed": "Αποτυχία διαγραφής της συνεδρίας",
"last": "Τουλάχιστον μία συνεδρία πρέπει να διατηρηθεί"
},
"title": "Διαγραφή συνεδρίας"
},
@ -84,6 +85,9 @@
"error": {
"failed": "Αποτυχία ενημέρωσης του πράκτορα"
}
},
"warning": {
"enable_server": "Ενεργοποίηση του διακομιστή API για χρήση πρακτόρων."
}
},
"agents": {

View File

@ -52,7 +52,8 @@
"delete": {
"content": "¿Estás seguro de eliminar esta sesión?",
"error": {
"failed": "Error al eliminar la sesión"
"failed": "Error al eliminar la sesión",
"last": "Debe mantenerse al menos una sesión"
},
"title": "Eliminar sesión"
},
@ -84,6 +85,9 @@
"error": {
"failed": "Error al actualizar el agente"
}
},
"warning": {
"enable_server": "Habilitar el servidor API para usar agentes."
}
},
"agents": {

View File

@ -52,7 +52,8 @@
"delete": {
"content": "Êtes-vous sûr de vouloir supprimer cette session ?",
"error": {
"failed": "Échec de la suppression de la session"
"failed": "Échec de la suppression de la session",
"last": "Au moins une session doit être conservée"
},
"title": "Supprimer la session"
},
@ -84,6 +85,9 @@
"error": {
"failed": "Échec de la mise à jour de l'agent"
}
},
"warning": {
"enable_server": "Permettre au serveur API d'utiliser des agents."
}
},
"agents": {

View File

@ -52,7 +52,8 @@
"delete": {
"content": "このセッションを削除してもよろしいですか?",
"error": {
"failed": "セッションの削除に失敗しました"
"failed": "セッションの削除に失敗しました",
"last": "少なくとも1つのセッションを維持する必要があります"
},
"title": "セッションを削除"
},
@ -84,6 +85,9 @@
"error": {
"failed": "エージェントの更新に失敗しました"
}
},
"warning": {
"enable_server": "APIサーバーがエージェントを使用できるようにする。"
}
},
"agents": {

View File

@ -52,7 +52,8 @@
"delete": {
"content": "Tem certeza de que deseja excluir esta sessão?",
"error": {
"failed": "Falha ao excluir a sessão"
"failed": "Falha ao excluir a sessão",
"last": "Pelo menos uma sessão deve ser mantida"
},
"title": "Excluir sessão"
},
@ -84,6 +85,9 @@
"error": {
"failed": "Falha ao atualizar o agente"
}
},
"warning": {
"enable_server": "Ativar o Servidor de API para usar agentes."
}
},
"agents": {

View File

@ -52,7 +52,8 @@
"delete": {
"content": "Вы уверены, что хотите удалить этот сеанс?",
"error": {
"failed": "Не удалось удалить сеанс"
"failed": "Не удалось удалить сеанс",
"last": "Должен быть сохранён хотя бы один сеанс"
},
"title": "Удалить сеанс"
},
@ -84,6 +85,9 @@
"error": {
"failed": "Не удалось обновить агента"
}
},
"warning": {
"enable_server": "Разрешить серверу API использовать агентов."
}
},
"agents": {

View File

@ -1,3 +1,4 @@
import { Alert } from '@heroui/react'
import { loggerService } from '@logger'
import { ContentSearch, ContentSearchRef } from '@renderer/components/ContentSearch'
import { HStack } from '@renderer/components/Layout'
@ -49,6 +50,7 @@ const Chat: FC<Props> = (props) => {
const chatMaxWidth = useChatMaxWidth()
const { chat } = useRuntime()
const { activeTopicOrSession, activeAgentId, activeSessionId } = chat
const { apiServer } = useSettings()
const mainRef = React.useRef<HTMLDivElement>(null)
const contentSearchRef = React.useRef<ContentSearchRef>(null)
@ -149,8 +151,15 @@ const Chat: FC<Props> = (props) => {
if (!sessionId) {
return () => <div> Active Session ID is invalid.</div>
}
if (!apiServer.enabled) {
return () => (
<div>
<Alert color="warning" title={t('agent.warning.enable_server')} />
</div>
)
}
return () => <AgentSessionMessages agentId={activeAgentId} sessionId={sessionId} />
}, [activeAgentId, activeSessionId])
}, [activeAgentId, activeSessionId, apiServer.enabled, t])
const SessionInputBar = useMemo(() => {
if (activeAgentId === null) {

View File

@ -1,21 +1,28 @@
import { Tooltip } from '@heroui/react'
import { loggerService } from '@logger'
import { ActionIconButton } from '@renderer/components/Buttons'
import { QuickPanelView } from '@renderer/components/QuickPanel'
import { useSession } from '@renderer/hooks/agents/useSession'
import { selectNewTopicLoading } from '@renderer/hooks/useMessageOperations'
import { getModel } from '@renderer/hooks/useModel'
import { useSettings } from '@renderer/hooks/useSettings'
import { useTimer } from '@renderer/hooks/useTimer'
import PasteService from '@renderer/services/PasteService'
import { useAppDispatch } from '@renderer/store'
import { pauseTrace } from '@renderer/services/SpanManagerService'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newMessage'
import { sendMessage as dispatchSendMessage } from '@renderer/store/thunk/messageThunk'
import type { Assistant, Message, Model, Topic } from '@renderer/types'
import { MessageBlock, MessageBlockStatus } from '@renderer/types/newMessage'
import { classNames } from '@renderer/utils'
import { abortCompletion } from '@renderer/utils/abortController'
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
import { getSendMessageShortcutLabel, isSendMessageKeyPressed } from '@renderer/utils/input'
import { createMainTextBlock, createMessage } from '@renderer/utils/messageUtils/create'
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
import { isEmpty } from 'lodash'
import React, { CSSProperties, FC, useCallback, useEffect, useRef, useState } from 'react'
import { CirclePause } from 'lucide-react'
import React, { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { v4 as uuid } from 'uuid'
@ -36,6 +43,7 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
const [text, setText] = useState(_text)
const [inputFocus, setInputFocus] = useState(false)
const { session } = useSession(agentId, sessionId)
const { apiServer } = useSettings()
const { sendMessageShortcut, fontSize, enableSpellCheck } = useSettings()
const textareaRef = useRef<TextAreaRef>(null)
@ -46,12 +54,37 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
const { setTimeoutTimer } = useTimer()
const dispatch = useAppDispatch()
const sessionTopicId = buildAgentSessionTopicId(sessionId)
const topicMessages = useAppSelector((state) => selectMessagesForTopic(state, sessionTopicId))
const loading = useAppSelector((state) => selectNewTopicLoading(state, sessionTopicId))
const focusTextarea = useCallback(() => {
textareaRef.current?.focus()
}, [])
const inputEmpty = isEmpty(text)
const sendDisabled = inputEmpty || !apiServer.enabled
const streamingAskIds = useMemo(() => {
if (!topicMessages) {
return []
}
const askIdSet = new Set<string>()
for (const message of topicMessages) {
if (!message) continue
if (message.status === 'processing' || message.status === 'pending') {
if (message.askId) {
askIdSet.add(message.askId)
} else if (message.id) {
askIdSet.add(message.id)
}
}
}
return Array.from(askIdSet)
}, [topicMessages])
const canAbort = loading && streamingAskIds.length > 0
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
//to check if the SendMessage key is pressed
@ -95,8 +128,27 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
}
}
const abortAgentSession = useCallback(async () => {
if (!streamingAskIds.length) {
logger.debug('No active agent session streams to abort', { sessionTopicId })
return
}
logger.info('Aborting agent session message generation', {
sessionTopicId,
askIds: streamingAskIds
})
for (const askId of streamingAskIds) {
abortCompletion(askId)
}
pauseTrace(sessionTopicId)
dispatch(newMessagesActions.setTopicLoading({ topicId: sessionTopicId, loading: false }))
}, [dispatch, sessionTopicId, streamingAskIds])
const sendMessage = useCallback(async () => {
if (inputEmpty) {
if (sendDisabled) {
return
}
@ -158,7 +210,7 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
}, [
agentId,
dispatch,
inputEmpty,
sendDisabled,
session?.agent_id,
session?.instructions,
session?.model,
@ -231,7 +283,16 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
onBlur={() => setInputFocus(false)}
/>
<div className="flex justify-end px-1">
<SendMessageButton sendMessage={sendMessage} disabled={inputEmpty} />
<div className="flex items-center gap-1">
<SendMessageButton sendMessage={sendMessage} disabled={sendDisabled} />
{canAbort && (
<Tooltip placement="top" content={t('chat.input.pause')}>
<ActionIconButton onClick={abortAgentSession} style={{ marginRight: -2 }}>
<CirclePause size={20} color="var(--color-error)" />
</ActionIconButton>
</Tooltip>
)}
</div>
</div>
</InputBarContainer>
</Container>

View File

@ -3,7 +3,7 @@ import { Assistant } from '@renderer/types'
import { FC, useRef } from 'react'
import styled from 'styled-components'
import { Agents } from './components/Agents'
import { AgentSection } from './components/AgentSection'
import Assistants from './components/Assistants'
interface AssistantsTabProps {
@ -17,7 +17,7 @@ const AssistantsTab: FC<AssistantsTabProps> = (props) => {
const containerRef = useRef<HTMLDivElement>(null)
return (
<Container className="assistants-tab" ref={containerRef}>
<Agents />
<AgentSection />
<Assistants {...props} />
</Container>
)

View File

@ -1,5 +1,6 @@
import { Spinner } from '@heroui/react'
import { Alert, Spinner } from '@heroui/react'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { AnimatePresence, motion } from 'framer-motion'
import { FC, memo } from 'react'
import { useTranslation } from 'react-i18next'
@ -12,6 +13,15 @@ const SessionsTab: FC<SessionsTabProps> = () => {
const { chat } = useRuntime()
const { activeAgentId } = chat
const { t } = useTranslation()
const { apiServer } = useSettings()
if (!apiServer.enabled) {
return (
<div>
<Alert color="warning" title={t('agent.warning.enable_server')} />
</div>
)
}
return (
<AnimatePresence mode="wait">

View File

@ -0,0 +1,39 @@
import { Alert } from '@heroui/react'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { useAppDispatch } from '@renderer/store'
import { addIknowAction } from '@renderer/store/runtime'
import { useTranslation } from 'react-i18next'
import { Agents } from './Agents'
import { SectionName } from './SectionName'
const ALERT_KEY = 'enable_api_server_to_use_agent'
export const AgentSection = () => {
const { t } = useTranslation()
const { apiServer } = useSettings()
const { iknow } = useRuntime()
const dispatch = useAppDispatch()
if (!apiServer.enabled) {
if (iknow[ALERT_KEY]) return null
return (
<Alert
color="warning"
title={t('agent.warning.enable_server')}
isClosable
onClose={() => {
dispatch(addIknowAction(ALERT_KEY))
}}
/>
)
}
return (
<div className="agents-tab h-full w-full">
<SectionName name={t('common.agent_other')} />
<Agents />
</div>
)
}

View File

@ -9,7 +9,6 @@ import { FC, useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import AgentItem from './AgentItem'
import { SectionName } from './SectionName'
interface AssistantsTabProps {}
@ -35,8 +34,7 @@ export const Agents: FC<AssistantsTabProps> = () => {
}, [isLoading, agents, activeAgentId, setActiveAgentId])
return (
<div className="agents-tab h-full w-full">
<SectionName name={t('common.agent_other')} />
<>
{isLoading && <Spinner />}
{error && <Alert color="danger" title={t('agent.list.error.failed')} />}
{!isLoading &&
@ -65,6 +63,6 @@ export const Agents: FC<AssistantsTabProps> = () => {
)
}}
/>
</div>
</>
)
}

View File

@ -13,11 +13,13 @@ interface SessionItemProps {
session: AgentSessionEntity
// use external agentId as SSOT, instead of session.agent_id
agentId: string
isDisabled?: boolean
isLoading?: boolean
onDelete: () => void
onPress: () => void
}
const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress }) => {
const SessionItem: FC<SessionItemProps> = ({ session, agentId, isDisabled, isLoading, onDelete, onPress }) => {
const { t } = useTranslation()
const { isOpen, onOpen, onClose } = useDisclosure()
const { chat } = useRuntime()
@ -38,7 +40,11 @@ const SessionItem: FC<SessionItemProps> = ({ session, agentId, onDelete, onPress
<>
<ContextMenu modal={false}>
<ContextMenuTrigger>
<ButtonContainer onPress={onPress} className={isActive ? 'active' : ''}>
<ButtonContainer
isDisabled={isDisabled}
isLoading={isLoading}
onPress={onPress}
className={isActive ? 'active' : ''}>
<SessionLabelContainer className="name" title={session.name ?? session.id}>
<SessionLabel />
</SessionLabelContainer>

View File

@ -1,9 +1,15 @@
import { Button, Spinner } from '@heroui/react'
import { SessionModal } from '@renderer/components/Popups/agent/SessionModal'
import { Alert, Button, Spinner } from '@heroui/react'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useAppDispatch } from '@renderer/store'
import { setActiveSessionIdAction, setActiveTopicOrSessionAction } from '@renderer/store/runtime'
import {
setActiveSessionIdAction,
setActiveTopicOrSessionAction,
setSessionWaitingAction
} from '@renderer/store/runtime'
import { CreateSessionForm } from '@renderer/types'
import { AnimatePresence, motion } from 'framer-motion'
import { Plus } from 'lucide-react'
import { memo, useCallback, useEffect } from 'react'
@ -19,9 +25,11 @@ interface SessionsProps {
const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
const { t } = useTranslation()
const { sessions, isLoading, deleteSession } = useSessions(agentId)
const { agent } = useAgent(agentId)
const { sessions, isLoading, error, deleteSession, createSession } = useSessions(agentId)
const updateSession = useUpdateSession(agentId)
const { chat } = useRuntime()
const { activeSessionId } = chat
const { activeSessionId, sessionWaiting } = chat
const dispatch = useAppDispatch()
const setActiveSessionId = useCallback(
@ -32,6 +40,39 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
[dispatch]
)
const handleCreateSession = useCallback(async () => {
if (!agent) return
const session = {
...agent,
id: undefined
} satisfies CreateSessionForm
const created = await createSession(session)
if (created) {
dispatch(setActiveSessionIdAction({ agentId, sessionId: created.id }))
}
}, [agent, agentId, createSession, dispatch])
const handleDeleteSession = useCallback(
async (id: string) => {
if (sessions.length === 1) {
window.toast.error(t('agent.session.delete.error.last'))
return
}
dispatch(setSessionWaitingAction({ id, value: true }))
const success = await deleteSession(id)
if (success) {
const newSessionId = sessions.find((s) => s.id !== id)?.id
if (newSessionId) {
dispatch(setActiveSessionIdAction({ agentId, sessionId: newSessionId }))
} else {
// may clear messages instead of forbidden deletion
}
}
dispatch(setSessionWaitingAction({ id, value: false }))
},
[agentId, deleteSession, dispatch, sessions]
)
const currentActiveSessionId = activeSessionId[agentId]
useEffect(() => {
@ -52,7 +93,7 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
)
}
// if (error) return
if (error) return <Alert color="danger" content={t('agent.session.get.error.failed')} />
return (
<motion.div
@ -64,20 +105,12 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: 0.1 }}>
<SessionModal
agentId={agentId}
onSessionCreated={(created) => setActiveSessionId(agentId, created.id)}
trigger={{
content: (
<Button
onPress={(e) => e.continuePropagation()}
className="mb-2 w-full justify-start bg-transparent text-foreground-500 hover:bg-accent">
<Plus size={16} className="mr-1 shrink-0" />
{t('agent.session.add.title')}
</Button>
)
}}
/>
<Button
onPress={handleCreateSession}
className="mb-2 w-full justify-start bg-transparent text-foreground-500 hover:bg-accent">
<Plus size={16} className="mr-1 shrink-0" />
{t('agent.session.add.title')}
</Button>
</motion.div>
<AnimatePresence>
{sessions.map((session, index) => (
@ -90,7 +123,9 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
<SessionItem
session={session}
agentId={agentId}
onDelete={() => deleteSession(session.id)}
isDisabled={sessionWaiting[session.id]}
isLoading={sessionWaiting[session.id]}
onDelete={() => handleDeleteSession(session.id)}
onPress={() => setActiveSessionId(agentId, session.id)}
/>
</motion.div>

View File

@ -18,6 +18,8 @@ export interface ChatState {
renamingTopics: string[]
/** topic ids that are newly renamed */
newlyRenamedTopics: string[]
/** is a session waiting for updating/deleting. undefined and false share same semantics. */
sessionWaiting: Record<string, boolean>
}
export interface WebSearchState {
@ -53,6 +55,7 @@ export interface RuntimeState {
export: ExportState
chat: ChatState
websearch: WebSearchState
iknow: Record<string, boolean>
}
export interface ExportState {
@ -89,11 +92,13 @@ const initialState: RuntimeState = {
activeTopicOrSession: 'topic',
activeSessionId: {},
renamingTopics: [],
newlyRenamedTopics: []
newlyRenamedTopics: [],
sessionWaiting: {}
},
websearch: {
activeSearches: {}
}
},
iknow: {}
}
const runtimeSlice = createSlice({
@ -179,6 +184,13 @@ const runtimeSlice = createSlice({
delete state.websearch.activeSearches[requestId]
}
state.websearch.activeSearches[requestId] = status
},
addIknowAction: (state, action: PayloadAction<string>) => {
state.iknow[action.payload] = true
},
setSessionWaitingAction: (state, action: PayloadAction<{ id: string; value: boolean }>) => {
const { id, value } = action.payload
state.chat.sessionWaiting[id] = value
}
}
})
@ -197,6 +209,7 @@ export const {
setResourcesPath,
setUpdateState,
setExportState,
addIknowAction,
// Chat related actions
toggleMultiSelectMode,
setSelectedMessageIds,
@ -206,6 +219,7 @@ export const {
setActiveTopicOrSessionAction,
setRenamingTopics,
setNewlyRenamedTopics,
setSessionWaitingAction,
// WebSearch related actions
setActiveSearches,
setWebSearchStatus