mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-25 11:20:07 +08:00
Refactor/agent align (#10276)
* Refactor agent streaming from EventEmitter to ReadableStream Replaced EventEmitter-based agent streaming with ReadableStream for better compatibility with AI SDK patterns. Modified SessionMessageService to return stream/completion pair instead of event emitter, updated HTTP handlers to use stream pumping, and added IPC contract for renderer-side message persistence. * Add accessible paths management to agent configuration Move accessible paths functionality from session modal to agent modal, add validation requiring at least one path, and update form handling to inherit agent paths in sessions. * Add provider_name field to model objects and improve display - Add provider_name field to ApiModel schema and transformation logic - Update model options to include providerName for better display - Improve provider label fallback chain in model transformation - Fix agent hook to use proper SWR key and conditional fetching - Enhance option rendering with better truncation and provider display * fix(i18n): Auto update translations for PR #10276 * Optimize chat components with memoization and shared layout - Wrap `SessionMessages` and `SessionInputBar` in `useMemo` to prevent unnecessary re-renders - Refactor `AgentSessionMessages` to use shared layout components and message grouping - Extract common styled components to `shared.tsx` for reuse across message components * Add smooth animations to SessionsTab and Sessions components - Replace static conditional rendering with Framer Motion animations for no-agent and session states - Animate session list items with staggered entrance and exit transitions - Add loading spinner animation with fade effect - Apply motion to session creation button with delayed entrance * Add loading state with spinner and i18n support to SessionsTab - Replace static "No active agent" message with a spinner and loading text - Integrate react-i18next for translation of loading message - Adjust animation timing and styling for smoother loading state transition * Support API models with provider_name field in getModelName - Add ApiModel type import and update function signature to accept ApiModel - Return formatted name using provider_name field for API models - Maintain backward compatibility for legacy models by looking up provider in store * Simplify provider display name logic and add debug logging - Replace complex fallback chain for provider display name with direct provider name access - Add console.log for model debugging in getModelName function * Extract model name from session model string - Use split and pop to isolate the model name after the colon - Fall back to the full model string if no colon is present - Maintain provider and group identifiers for model object consistency * Improve model name resolution for agent sessions - Extract actual model ID from session model string and resolve model details - Use resolved model name, provider, and group when available instead of defaults - Remove redundant API model handling in getModelName function * Set default active agent and session on load - Automatically select first agent if none active after loading - Automatically select first session per agent if none active after loading - Prevent empty selection states in UI components --------- Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
parent
fcacc50fdc
commit
36f86ff2b9
@ -89,6 +89,9 @@ export enum IpcChannel {
|
||||
// Python
|
||||
Python_Execute = 'python:execute',
|
||||
|
||||
// agent messages
|
||||
AgentMessage_PersistExchange = 'agent-message:persist-exchange',
|
||||
|
||||
//copilot
|
||||
Copilot_GetAuthMessage = 'copilot:get-auth-message',
|
||||
Copilot_GetCopilotToken = 'copilot:get-copilot-token',
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { AgentStreamEvent } from '@main/services/agents/interfaces/AgentStreamInterface'
|
||||
import { Request, Response } from 'express'
|
||||
|
||||
import { agentService, sessionMessageService, sessionService } from '../../../../services/agents'
|
||||
@ -44,7 +43,12 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Cache-Control')
|
||||
|
||||
const abortController = new AbortController()
|
||||
const messageStream = sessionMessageService.createSessionMessage(session, messageData, abortController)
|
||||
const { stream, completion } = await sessionMessageService.createSessionMessage(
|
||||
session,
|
||||
messageData,
|
||||
abortController
|
||||
)
|
||||
const reader = stream.getReader()
|
||||
|
||||
// Track stream lifecycle so we keep the SSE connection open until persistence finishes
|
||||
let responseEnded = false
|
||||
@ -61,7 +65,7 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
|
||||
|
||||
responseEnded = true
|
||||
try {
|
||||
res.write('data: {"type":"finish"}\n\n')
|
||||
// res.write('data: {"type":"finish"}\n\n')
|
||||
res.write('data: [DONE]\n\n')
|
||||
} catch (writeError) {
|
||||
logger.error('Error writing final sentinel to SSE stream:', { error: writeError as Error })
|
||||
@ -92,93 +96,78 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
|
||||
if (responseEnded) return
|
||||
logger.info(`Client disconnected from streaming message for session: ${sessionId}`)
|
||||
responseEnded = true
|
||||
messageStream.removeAllListeners()
|
||||
abortController.abort('Client disconnected')
|
||||
reader.cancel('Client disconnected').catch(() => {})
|
||||
}
|
||||
|
||||
req.on('close', handleDisconnect)
|
||||
req.on('aborted', handleDisconnect)
|
||||
res.on('close', handleDisconnect)
|
||||
|
||||
// Handle stream events
|
||||
messageStream.on('data', (event: AgentStreamEvent) => {
|
||||
if (responseEnded) return
|
||||
|
||||
const pumpStream = async () => {
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'chunk':
|
||||
// Format UIMessageChunk as SSE event following AI SDK protocol
|
||||
res.write(`data: ${JSON.stringify(event.chunk)}\n\n`)
|
||||
while (!responseEnded) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
// Send error as AI SDK error chunk
|
||||
const errorChunk = {
|
||||
res.write(`data: ${JSON.stringify(value)}\n\n`)
|
||||
}
|
||||
|
||||
streamFinished = true
|
||||
finalizeResponse()
|
||||
} catch (error) {
|
||||
if (responseEnded) return
|
||||
logger.error('Error reading agent stream:', { error })
|
||||
try {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
type: 'error',
|
||||
errorText: event.error?.message || 'Stream processing error'
|
||||
}
|
||||
res.write(`data: ${JSON.stringify(errorChunk)}\n\n`)
|
||||
logger.error(`Streaming message error for session: ${sessionId}:`, event.error)
|
||||
|
||||
streamFinished = true
|
||||
finalizeResponse()
|
||||
break
|
||||
}
|
||||
|
||||
case 'complete': {
|
||||
logger.info(`Streaming message completed for session: ${sessionId}`)
|
||||
// res.write(`data: ${JSON.stringify({ type: 'complete', result: event.result })}\n\n`)
|
||||
|
||||
streamFinished = true
|
||||
finalizeResponse()
|
||||
break
|
||||
}
|
||||
|
||||
case 'cancelled': {
|
||||
logger.info(`Streaming message cancelled for session: ${sessionId}`)
|
||||
// res.write(`data: ${JSON.stringify({ type: 'cancelled' })}\n\n`)
|
||||
streamFinished = true
|
||||
finalizeResponse()
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
// Handle other event types as generic data
|
||||
logger.info(`Streaming message event for session: ${sessionId}:`, { event })
|
||||
// res.write(`data: ${JSON.stringify(event)}\n\n`)
|
||||
break
|
||||
}
|
||||
} catch (writeError) {
|
||||
logger.error('Error writing to SSE stream:', { error: writeError })
|
||||
if (!responseEnded) {
|
||||
responseEnded = true
|
||||
res.end()
|
||||
error: {
|
||||
message: (error as Error).message || 'Stream processing error',
|
||||
type: 'stream_error',
|
||||
code: 'stream_processing_failed'
|
||||
}
|
||||
})}\n\n`
|
||||
)
|
||||
} catch (writeError) {
|
||||
logger.error('Error writing stream error to SSE:', { error: writeError })
|
||||
}
|
||||
responseEnded = true
|
||||
res.end()
|
||||
}
|
||||
}
|
||||
|
||||
pumpStream().catch((error) => {
|
||||
logger.error('Pump stream failure:', { error })
|
||||
})
|
||||
|
||||
// Handle stream errors
|
||||
messageStream.on('error', (error: Error) => {
|
||||
if (responseEnded) return
|
||||
|
||||
logger.error(`Stream error for session: ${sessionId}:`, { error })
|
||||
try {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
message: error.message || 'Stream processing error',
|
||||
type: 'stream_error',
|
||||
code: 'stream_processing_failed'
|
||||
}
|
||||
})}\n\n`
|
||||
)
|
||||
} catch (writeError) {
|
||||
logger.error('Error writing error to SSE stream:', { error: writeError })
|
||||
}
|
||||
responseEnded = true
|
||||
res.end()
|
||||
})
|
||||
completion
|
||||
.then(() => {
|
||||
streamFinished = true
|
||||
finalizeResponse()
|
||||
})
|
||||
.catch((error) => {
|
||||
if (responseEnded) return
|
||||
logger.error(`Streaming message error for session: ${sessionId}:`, error)
|
||||
try {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
message: (error as { message?: string })?.message || 'Stream processing error',
|
||||
type: 'stream_error',
|
||||
code: 'stream_processing_failed'
|
||||
}
|
||||
})}\n\n`
|
||||
)
|
||||
} catch (writeError) {
|
||||
logger.error('Error writing completion error to SSE stream:', { error: writeError })
|
||||
}
|
||||
responseEnded = true
|
||||
res.end()
|
||||
})
|
||||
|
||||
// Set a timeout to prevent hanging indefinitely
|
||||
const timeout = setTimeout(
|
||||
@ -199,6 +188,8 @@ export const createMessage = async (req: Request, res: Response): Promise<void>
|
||||
} catch (writeError) {
|
||||
logger.error('Error writing timeout to SSE stream:', { error: writeError })
|
||||
}
|
||||
abortController.abort('stream timeout')
|
||||
reader.cancel('stream timeout').catch(() => {})
|
||||
responseEnded = true
|
||||
res.end()
|
||||
}
|
||||
|
||||
@ -190,13 +190,15 @@ export async function validateModelId(
|
||||
|
||||
export function transformModelToOpenAI(model: Model, providers: Provider[]): ApiModel {
|
||||
const provider = providers.find((p) => p.id === model.provider)
|
||||
const providerDisplayName = provider?.name
|
||||
return {
|
||||
id: `${model.provider}:${model.id}`,
|
||||
object: 'model',
|
||||
name: model.name,
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
owned_by: model.owned_by || model.provider,
|
||||
owned_by: model.owned_by || providerDisplayName || model.provider,
|
||||
provider: model.provider,
|
||||
provider_name: providerDisplayName,
|
||||
provider_type: provider?.type,
|
||||
provider_model_id: model.id
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import checkDiskSpace from 'check-disk-space'
|
||||
import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron'
|
||||
import fontList from 'font-list'
|
||||
|
||||
import { agentMessageRepository } from './services/agents/database'
|
||||
import { apiServerService } from './services/ApiServerService'
|
||||
import appService from './services/AppService'
|
||||
import AppUpdater from './services/AppUpdater'
|
||||
@ -199,6 +200,15 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.AgentMessage_PersistExchange, async (_event, payload) => {
|
||||
try {
|
||||
return await agentMessageRepository.persistExchange(payload)
|
||||
} catch (error) {
|
||||
logger.error('Failed to persist agent session messages', error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
//only for mac
|
||||
if (isMac) {
|
||||
ipcMain.handle(IpcChannel.App_MacIsProcessTrusted, (): boolean => {
|
||||
|
||||
35
src/main/services/agents/TODO.md
Normal file
35
src/main/services/agents/TODO.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Agents Service Refactor TODO (interface-level)
|
||||
|
||||
- [x] **SessionMessageService.createSessionMessage**
|
||||
- Replace the current `EventEmitter` that emits `UIMessageChunk` with a readable stream of `TextStreamPart` objects (same shape produced by `/api/messages` in `messageThunk`).
|
||||
- Update `startSessionMessageStream` to call a new adapter (`claudeToTextStreamPart(chunk)`) that maps Claude Code chunk payloads to `{ type: 'text-delta' | 'tool-call' | ... }` parts used by `AiSdkToChunkAdapter`.
|
||||
- Add a secondary return value (promise) resolving to the persisted `ModelMessage[]` once streaming completes, so the renderer thunk can await save confirmation.
|
||||
|
||||
- [x] **main -> renderer transport**
|
||||
- Update the existing SSE handler in `src/main/apiServer/routes/agents/handlers/messages.ts` (e.g., `createMessage`) to forward the new `TextStreamPart` stream over HTTP, preserving the current agent endpoint contract.
|
||||
- Keep abort handling compatible with the current HTTP server (honor `AbortController` on the request to terminate the stream).
|
||||
|
||||
- [x] **renderer thunk integration**
|
||||
- Introduce a thin IPC contract (e.g., `AgentMessagePersistence`) surfaced by `src/main/services/agents/database/index.ts` so the renderer thunk can request session-message writes without going through `SessionMessageService`.
|
||||
- Define explicit entry points on the main side:
|
||||
- `persistUserMessage({ sessionId, agentSessionId, payload, createdAt?, metadata? })`
|
||||
- `persistAssistantMessage({ sessionId, agentSessionId, payload, createdAt?, metadata? })`
|
||||
- `persistExchange({ sessionId, agentSessionId, user, assistant })` which runs the above in a single transaction and returns both records.
|
||||
- Export these helpers via an `agentMessageRepository` object so both IPC handlers and legacy services share the same persistence path.
|
||||
- Normalize persisted payloads to `{ message, blocks }` matching the renderer schema instead of AI-SDK `ModelMessage` chunks.
|
||||
- Extend `messageThunk.sendMessage` to call the agent transport when the topic corresponds to a session, pipe chunks through `createStreamProcessor` + `AiSdkToChunkAdapter`, and invoke the new persistence interface once streaming resolves.
|
||||
- Replace `useSession().createSessionMessage` optimistic insert with dispatching the thunk so Redux/Dexie persistence happens via the shared save helpers.
|
||||
|
||||
- [x] **persistence alignment**
|
||||
- Remove `persistUserMessage` / `persistAssistantMessage` calls from `SessionMessageService`; instead expose a `SessionMessageRepository` in `main` that the thunk invokes via existing Dexie helpers.
|
||||
- On renderer side, persist agent exchanges via IPC after streaming completes, storing `{ message, blocks }` payloads while skipping Dexie writes for agent sessions so the single source of truth remains `session_messages`.
|
||||
|
||||
- [x] **Blocks renderer**
|
||||
- Replace `AgentSessionMessages` simple `<div>` render with the shared `Blocks` component (`src/renderer/src/pages/home/Messages/Blocks`) wired to the Redux store.
|
||||
- Adjust `useSession` to only fetch metadata (e.g., session info) and rely on store selectors for message list.
|
||||
|
||||
- [x] **API client clean-up**
|
||||
- Remove `AgentApiClient.createMessage` direct POST once thunk is in place; calls should go through renderer thunk -> stream -> final persistence.
|
||||
|
||||
- [ ] **Regression tests**
|
||||
- Add integration test to assert agent sessions render incremental text the same way as standard assistant messages.
|
||||
@ -9,3 +9,6 @@
|
||||
|
||||
// Drizzle ORM schemas
|
||||
export * from './schema'
|
||||
|
||||
// Repository helpers
|
||||
export * from './sessionMessageRepository'
|
||||
|
||||
181
src/main/services/agents/database/sessionMessageRepository.ts
Normal file
181
src/main/services/agents/database/sessionMessageRepository.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type {
|
||||
AgentMessageAssistantPersistPayload,
|
||||
AgentMessagePersistExchangePayload,
|
||||
AgentMessagePersistExchangeResult,
|
||||
AgentMessageUserPersistPayload,
|
||||
AgentPersistedMessage,
|
||||
AgentSessionMessageEntity
|
||||
} from '@types'
|
||||
|
||||
import { BaseService } from '../BaseService'
|
||||
import type { InsertSessionMessageRow } from './schema'
|
||||
import { sessionMessagesTable } from './schema'
|
||||
|
||||
const logger = loggerService.withContext('AgentMessageRepository')
|
||||
|
||||
type TxClient = any
|
||||
|
||||
export type PersistUserMessageParams = AgentMessageUserPersistPayload & {
|
||||
sessionId: string
|
||||
agentSessionId?: string
|
||||
tx?: TxClient
|
||||
}
|
||||
|
||||
export type PersistAssistantMessageParams = AgentMessageAssistantPersistPayload & {
|
||||
sessionId: string
|
||||
agentSessionId: string
|
||||
tx?: TxClient
|
||||
}
|
||||
|
||||
type PersistExchangeParams = AgentMessagePersistExchangePayload & {
|
||||
tx?: TxClient
|
||||
}
|
||||
|
||||
type PersistExchangeResult = AgentMessagePersistExchangeResult
|
||||
|
||||
class AgentMessageRepository extends BaseService {
|
||||
private static instance: AgentMessageRepository | null = null
|
||||
|
||||
static getInstance(): AgentMessageRepository {
|
||||
if (!AgentMessageRepository.instance) {
|
||||
AgentMessageRepository.instance = new AgentMessageRepository()
|
||||
}
|
||||
|
||||
return AgentMessageRepository.instance
|
||||
}
|
||||
|
||||
private serializeMessage(payload: AgentPersistedMessage): string {
|
||||
return JSON.stringify(payload)
|
||||
}
|
||||
|
||||
private serializeMetadata(metadata?: Record<string, unknown>): string | undefined {
|
||||
if (!metadata) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(metadata)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to serialize session message metadata', error as Error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private deserialize(row: any): AgentSessionMessageEntity {
|
||||
if (!row) return row
|
||||
|
||||
const deserialized = { ...row }
|
||||
|
||||
if (typeof deserialized.content === 'string') {
|
||||
try {
|
||||
deserialized.content = JSON.parse(deserialized.content)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse session message content JSON', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof deserialized.metadata === 'string') {
|
||||
try {
|
||||
deserialized.metadata = JSON.parse(deserialized.metadata)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse session message metadata JSON', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
return deserialized
|
||||
}
|
||||
|
||||
private getWriter(tx?: TxClient): TxClient {
|
||||
return tx ?? this.database
|
||||
}
|
||||
|
||||
async persistUserMessage(params: PersistUserMessageParams): Promise<AgentSessionMessageEntity> {
|
||||
await AgentMessageRepository.initialize()
|
||||
this.ensureInitialized()
|
||||
|
||||
const writer = this.getWriter(params.tx)
|
||||
const now = params.createdAt ?? params.payload.message.createdAt ?? new Date().toISOString()
|
||||
|
||||
const insertData: InsertSessionMessageRow = {
|
||||
session_id: params.sessionId,
|
||||
role: params.payload.message.role,
|
||||
content: this.serializeMessage(params.payload),
|
||||
agent_session_id: params.agentSessionId ?? '',
|
||||
metadata: this.serializeMetadata(params.metadata),
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
}
|
||||
|
||||
const [saved] = await writer.insert(sessionMessagesTable).values(insertData).returning()
|
||||
|
||||
return this.deserialize(saved)
|
||||
}
|
||||
|
||||
async persistAssistantMessage(params: PersistAssistantMessageParams): Promise<AgentSessionMessageEntity> {
|
||||
await AgentMessageRepository.initialize()
|
||||
this.ensureInitialized()
|
||||
|
||||
const writer = this.getWriter(params.tx)
|
||||
const now = params.createdAt ?? params.payload.message.createdAt ?? new Date().toISOString()
|
||||
|
||||
const insertData: InsertSessionMessageRow = {
|
||||
session_id: params.sessionId,
|
||||
role: params.payload.message.role,
|
||||
content: this.serializeMessage(params.payload),
|
||||
agent_session_id: params.agentSessionId,
|
||||
metadata: this.serializeMetadata(params.metadata),
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
}
|
||||
|
||||
const [saved] = await writer.insert(sessionMessagesTable).values(insertData).returning()
|
||||
|
||||
return this.deserialize(saved)
|
||||
}
|
||||
|
||||
async persistExchange(params: PersistExchangeParams): Promise<PersistExchangeResult> {
|
||||
await AgentMessageRepository.initialize()
|
||||
this.ensureInitialized()
|
||||
|
||||
const { sessionId, agentSessionId, user, assistant } = params
|
||||
|
||||
const result = await this.database.transaction(async (tx) => {
|
||||
const exchangeResult: PersistExchangeResult = {}
|
||||
|
||||
if (user?.payload) {
|
||||
if (!user.payload.message?.role) {
|
||||
throw new Error('User message payload missing role')
|
||||
}
|
||||
exchangeResult.userMessage = await this.persistUserMessage({
|
||||
sessionId,
|
||||
agentSessionId,
|
||||
payload: user.payload,
|
||||
metadata: user.metadata,
|
||||
createdAt: user.createdAt,
|
||||
tx
|
||||
})
|
||||
}
|
||||
|
||||
if (assistant?.payload) {
|
||||
if (!assistant.payload.message?.role) {
|
||||
throw new Error('Assistant message payload missing role')
|
||||
}
|
||||
exchangeResult.assistantMessage = await this.persistAssistantMessage({
|
||||
sessionId,
|
||||
agentSessionId,
|
||||
payload: assistant.payload,
|
||||
metadata: assistant.metadata,
|
||||
createdAt: assistant.createdAt,
|
||||
tx
|
||||
})
|
||||
}
|
||||
|
||||
return exchangeResult
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
export const agentMessageRepository = AgentMessageRepository.getInstance()
|
||||
@ -4,12 +4,12 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
import { GetAgentSessionResponse } from '@types'
|
||||
import { UIMessageChunk } from 'ai'
|
||||
import type { TextStreamPart } from 'ai'
|
||||
|
||||
// Generic agent stream event that works with any agent type
|
||||
export interface AgentStreamEvent {
|
||||
type: 'chunk' | 'error' | 'complete' | 'cancelled'
|
||||
chunk?: UIMessageChunk // Standard AI SDK chunk for UI consumption
|
||||
chunk?: TextStreamPart<any> // Standard AI SDK chunk for UI consumption
|
||||
error?: Error
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import type {
|
||||
AgentSessionMessageEntity,
|
||||
@ -7,29 +5,22 @@ import type {
|
||||
GetAgentSessionResponse,
|
||||
ListOptions
|
||||
} from '@types'
|
||||
import { ModelMessage, UIMessage, UIMessageChunk } from 'ai'
|
||||
import { convertToModelMessages, readUIMessageStream } from 'ai'
|
||||
import { ModelMessage, TextStreamPart } from 'ai'
|
||||
import { desc, eq } from 'drizzle-orm'
|
||||
|
||||
import { BaseService } from '../BaseService'
|
||||
import { InsertSessionMessageRow, sessionMessagesTable } from '../database/schema'
|
||||
import { sessionMessagesTable } from '../database/schema'
|
||||
import { AgentStreamEvent } from '../interfaces/AgentStreamInterface'
|
||||
import ClaudeCodeService from './claudecode'
|
||||
|
||||
const logger = loggerService.withContext('SessionMessageService')
|
||||
|
||||
// Collapse a UIMessageChunk stream into a final UIMessage, then convert to ModelMessage[]
|
||||
export async function chunksToModelMessages(
|
||||
chunkStream: ReadableStream<UIMessageChunk>,
|
||||
priorUiHistory: UIMessage[] = []
|
||||
): Promise<ModelMessage[]> {
|
||||
let latest: UIMessage | undefined
|
||||
|
||||
for await (const uiMsg of readUIMessageStream({ stream: chunkStream })) {
|
||||
latest = uiMsg // each yield is a newer state; keep the last one
|
||||
}
|
||||
|
||||
const uiMessages = latest ? [...priorUiHistory, latest] : priorUiHistory
|
||||
return convertToModelMessages(uiMessages) // -> ModelMessage[]
|
||||
type SessionStreamResult = {
|
||||
stream: ReadableStream<TextStreamPart<Record<string, any>>>
|
||||
completion: Promise<{
|
||||
userMessage?: AgentSessionMessageEntity
|
||||
assistantMessage?: AgentSessionMessageEntity
|
||||
}>
|
||||
}
|
||||
|
||||
// Ensure errors emitted through SSE are serializable
|
||||
@ -51,71 +42,69 @@ function serializeError(error: unknown): { message: string; name?: string; stack
|
||||
}
|
||||
}
|
||||
|
||||
// Chunk accumulator class to collect and reconstruct streaming data
|
||||
class ChunkAccumulator {
|
||||
private streamedChunks: UIMessageChunk[] = []
|
||||
private agentType: string = 'unknown'
|
||||
class TextStreamAccumulator {
|
||||
private textBuffer = ''
|
||||
private totalText = ''
|
||||
private readonly toolCalls = new Map<string, { toolName?: string; input?: unknown }>()
|
||||
private readonly toolResults = new Map<string, unknown>()
|
||||
|
||||
addChunk(chunk: UIMessageChunk): void {
|
||||
this.streamedChunks.push(chunk)
|
||||
}
|
||||
|
||||
// Create a ReadableStream from accumulated chunks
|
||||
createChunkStream(): ReadableStream<UIMessageChunk> {
|
||||
const chunks = [...this.streamedChunks]
|
||||
|
||||
return new ReadableStream<UIMessageChunk>({
|
||||
start(controller) {
|
||||
// Enqueue all chunks
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(chunk)
|
||||
add(part: TextStreamPart<Record<string, any>>): void {
|
||||
switch (part.type) {
|
||||
case 'text-start':
|
||||
this.textBuffer = ''
|
||||
break
|
||||
case 'text-delta':
|
||||
if (part.text) {
|
||||
this.textBuffer += part.text
|
||||
}
|
||||
controller.close()
|
||||
break
|
||||
case 'text-end': {
|
||||
const blockText = (part.providerMetadata?.text?.value as string | undefined) ?? this.textBuffer
|
||||
if (blockText) {
|
||||
this.totalText += blockText
|
||||
}
|
||||
this.textBuffer = ''
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Convert accumulated chunks to ModelMessages using chunksToModelMessages
|
||||
async toModelMessages(priorUiHistory: UIMessage[] = []): Promise<ModelMessage[]> {
|
||||
const chunkStream = this.createChunkStream()
|
||||
return await chunksToModelMessages(chunkStream, priorUiHistory)
|
||||
case 'tool-call':
|
||||
if (part.toolCallId) {
|
||||
this.toolCalls.set(part.toolCallId, {
|
||||
toolName: part.toolName,
|
||||
input: part.input ?? part.args ?? part.providerMetadata?.raw?.input
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'tool-result':
|
||||
if (part.toolCallId) {
|
||||
this.toolResults.set(part.toolCallId, part.output ?? part.result ?? part.providerMetadata?.raw)
|
||||
}
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
toModelMessage(role: ModelMessage['role'] = 'assistant'): ModelMessage {
|
||||
// Reconstruct the content from chunks
|
||||
let textContent = ''
|
||||
const toolCalls: any[] = []
|
||||
const content = this.totalText || this.textBuffer || ''
|
||||
|
||||
for (const chunk of this.streamedChunks) {
|
||||
if (chunk.type === 'text-delta' && 'delta' in chunk) {
|
||||
textContent += chunk.delta
|
||||
} else if (chunk.type === 'tool-input-available' && 'toolCallId' in chunk && 'toolName' in chunk) {
|
||||
// Handle tool calls - use tool-input-available chunks
|
||||
const toolCall = {
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: chunk.toolName,
|
||||
args: chunk.input || {}
|
||||
}
|
||||
toolCalls.push(toolCall)
|
||||
}
|
||||
}
|
||||
const toolInvocations = Array.from(this.toolCalls.entries()).map(([toolCallId, info]) => ({
|
||||
toolCallId,
|
||||
toolName: info.toolName,
|
||||
args: info.input,
|
||||
result: this.toolResults.get(toolCallId)
|
||||
}))
|
||||
|
||||
const message: any = {
|
||||
const message: Record<string, unknown> = {
|
||||
role,
|
||||
content: textContent
|
||||
content
|
||||
}
|
||||
|
||||
// Add tool invocations if any
|
||||
if (toolCalls.length > 0) {
|
||||
message.toolInvocations = toolCalls
|
||||
if (toolInvocations.length > 0) {
|
||||
message.toolInvocations = toolInvocations
|
||||
}
|
||||
|
||||
return message as ModelMessage
|
||||
}
|
||||
|
||||
getAgentType(): string {
|
||||
return this.agentType
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionMessageService extends BaseService {
|
||||
@ -170,28 +159,21 @@ export class SessionMessageService extends BaseService {
|
||||
return { messages }
|
||||
}
|
||||
|
||||
createSessionMessage(
|
||||
async createSessionMessage(
|
||||
session: GetAgentSessionResponse,
|
||||
messageData: CreateSessionMessageRequest,
|
||||
abortController: AbortController
|
||||
): EventEmitter {
|
||||
): Promise<SessionStreamResult> {
|
||||
this.ensureInitialized()
|
||||
|
||||
// Create a new EventEmitter to manage the session message lifecycle
|
||||
const sessionStream = new EventEmitter()
|
||||
|
||||
// No parent validation needed, start immediately
|
||||
this.startSessionMessageStream(session, messageData, sessionStream, abortController)
|
||||
|
||||
return sessionStream
|
||||
return await this.startSessionMessageStream(session, messageData, abortController)
|
||||
}
|
||||
|
||||
private async startSessionMessageStream(
|
||||
session: GetAgentSessionResponse,
|
||||
req: CreateSessionMessageRequest,
|
||||
sessionStream: EventEmitter,
|
||||
abortController: AbortController
|
||||
): Promise<void> {
|
||||
): Promise<SessionStreamResult> {
|
||||
const agentSessionId = await this.getLastAgentSessionId(session.id)
|
||||
let newAgentSessionId = ''
|
||||
logger.debug('Session Message stream message data:', { message: req, session_id: agentSessionId })
|
||||
@ -202,98 +184,98 @@ export class SessionMessageService extends BaseService {
|
||||
throw new Error('Unsupported agent type for streaming')
|
||||
}
|
||||
|
||||
// Create the streaming agent invocation (using invokeStream for streaming)
|
||||
const claudeStream = await this.cc.invoke(req.content, session, abortController, agentSessionId)
|
||||
const accumulator = new TextStreamAccumulator()
|
||||
|
||||
// Use chunk accumulator to manage streaming data
|
||||
const accumulator = new ChunkAccumulator()
|
||||
let resolveCompletion!: (value: {
|
||||
userMessage?: AgentSessionMessageEntity
|
||||
assistantMessage?: AgentSessionMessageEntity
|
||||
}) => void
|
||||
let rejectCompletion!: (reason?: unknown) => void
|
||||
|
||||
// Handle agent stream events (agent-agnostic)
|
||||
claudeStream.on('data', async (event: any) => {
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'chunk':
|
||||
// Forward UIMessageChunk directly and collect raw agent messages
|
||||
if (event.chunk) {
|
||||
const chunk = event.chunk as UIMessageChunk
|
||||
if (chunk.type === 'start' && chunk.messageId) {
|
||||
newAgentSessionId = chunk.messageId
|
||||
const completion = new Promise<{
|
||||
userMessage?: AgentSessionMessageEntity
|
||||
assistantMessage?: AgentSessionMessageEntity
|
||||
}>((resolve, reject) => {
|
||||
resolveCompletion = resolve
|
||||
rejectCompletion = reject
|
||||
})
|
||||
|
||||
let finished = false
|
||||
|
||||
const cleanup = () => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
claudeStream.removeAllListeners()
|
||||
}
|
||||
|
||||
const stream = new ReadableStream<TextStreamPart<Record<string, any>>>({
|
||||
start: (controller) => {
|
||||
claudeStream.on('data', async (event: AgentStreamEvent) => {
|
||||
if (finished) return
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'chunk': {
|
||||
const chunk = event.chunk as TextStreamPart<Record<string, any>> | undefined
|
||||
if (!chunk) {
|
||||
logger.warn('Received agent chunk event without chunk payload')
|
||||
return
|
||||
}
|
||||
|
||||
if (chunk.type === 'start' && chunk.messageId) {
|
||||
newAgentSessionId = chunk.messageId
|
||||
}
|
||||
|
||||
accumulator.add(chunk)
|
||||
controller.enqueue(chunk)
|
||||
break
|
||||
}
|
||||
accumulator.addChunk(chunk)
|
||||
|
||||
sessionStream.emit('data', {
|
||||
type: 'chunk',
|
||||
chunk
|
||||
})
|
||||
} else {
|
||||
logger.warn('Received agent chunk event without chunk payload')
|
||||
}
|
||||
break
|
||||
case 'error': {
|
||||
const stderrMessage = (event as any)?.data?.stderr as string | undefined
|
||||
const underlyingError = event.error ?? (stderrMessage ? new Error(stderrMessage) : undefined)
|
||||
cleanup()
|
||||
const streamError = underlyingError ?? new Error('Stream error')
|
||||
controller.error(streamError)
|
||||
rejectCompletion(serializeError(streamError))
|
||||
break
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
const underlyingError = event.error || (event.data?.stderr ? new Error(event.data.stderr) : undefined)
|
||||
case 'complete': {
|
||||
cleanup()
|
||||
controller.close()
|
||||
resolveCompletion({})
|
||||
break
|
||||
}
|
||||
|
||||
sessionStream.emit('data', {
|
||||
type: 'error',
|
||||
error: serializeError(underlyingError),
|
||||
persistScheduled: false
|
||||
})
|
||||
// Always emit a complete chunk at the end
|
||||
sessionStream.emit('data', {
|
||||
type: 'complete',
|
||||
persistScheduled: false
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'cancelled': {
|
||||
cleanup()
|
||||
controller.close()
|
||||
resolveCompletion({})
|
||||
break
|
||||
}
|
||||
|
||||
case 'complete': {
|
||||
try {
|
||||
const persisted = await this.database.transaction(async (tx) => {
|
||||
const userMessage = await this.persistUserMessage(tx, session.id, req.content, newAgentSessionId)
|
||||
const assistantMessage = await this.persistAssistantMessage({
|
||||
tx,
|
||||
session,
|
||||
accumulator,
|
||||
agentSessionId: newAgentSessionId
|
||||
default:
|
||||
logger.warn('Unknown event type from Claude Code service:', {
|
||||
type: event.type
|
||||
})
|
||||
|
||||
return { userMessage, assistantMessage }
|
||||
})
|
||||
|
||||
sessionStream.emit('data', {
|
||||
type: 'persisted',
|
||||
message: persisted.assistantMessage,
|
||||
userMessage: persisted.userMessage
|
||||
})
|
||||
} catch (persistError) {
|
||||
sessionStream.emit('data', {
|
||||
type: 'persist-error',
|
||||
error: serializeError(persistError)
|
||||
})
|
||||
} finally {
|
||||
// Always emit a complete chunk at the end
|
||||
sessionStream.emit('data', {
|
||||
type: 'complete',
|
||||
persistScheduled: true
|
||||
})
|
||||
break
|
||||
}
|
||||
break
|
||||
} catch (error) {
|
||||
cleanup()
|
||||
controller.error(error)
|
||||
rejectCompletion(serializeError(error))
|
||||
}
|
||||
|
||||
default:
|
||||
logger.warn('Unknown event type from Claude Code service:', {
|
||||
type: event.type
|
||||
})
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error handling Claude Code stream event:', { error })
|
||||
sessionStream.emit('data', {
|
||||
type: 'error',
|
||||
error: serializeError(error)
|
||||
})
|
||||
},
|
||||
cancel: (reason) => {
|
||||
cleanup()
|
||||
abortController.abort(typeof reason === 'string' ? reason : 'stream cancelled')
|
||||
resolveCompletion({})
|
||||
}
|
||||
})
|
||||
|
||||
return { stream, completion }
|
||||
}
|
||||
|
||||
private async getLastAgentSessionId(sessionId: string): Promise<string> {
|
||||
@ -317,75 +299,6 @@ export class SessionMessageService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
async persistUserMessage(
|
||||
tx: any,
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
agentSessionId: string
|
||||
): Promise<AgentSessionMessageEntity> {
|
||||
this.ensureInitialized()
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const insertData: InsertSessionMessageRow = {
|
||||
session_id: sessionId,
|
||||
role: 'user',
|
||||
content: JSON.stringify({ role: 'user', content: prompt }),
|
||||
agent_session_id: agentSessionId,
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
}
|
||||
|
||||
const [saved] = await tx.insert(sessionMessagesTable).values(insertData).returning()
|
||||
|
||||
return this.deserializeSessionMessage(saved) as AgentSessionMessageEntity
|
||||
}
|
||||
|
||||
private async persistAssistantMessage({
|
||||
tx,
|
||||
session,
|
||||
accumulator,
|
||||
agentSessionId
|
||||
}: {
|
||||
tx: any
|
||||
session: GetAgentSessionResponse
|
||||
accumulator: ChunkAccumulator
|
||||
agentSessionId: string
|
||||
}): Promise<AgentSessionMessageEntity> {
|
||||
if (!session?.id) {
|
||||
const missingSessionError = new Error('Missing session_id for persisted message')
|
||||
logger.error('error persisting session message', { error: missingSessionError })
|
||||
throw missingSessionError
|
||||
}
|
||||
|
||||
const sessionId = session.id
|
||||
const now = new Date().toISOString()
|
||||
|
||||
try {
|
||||
// Use chunksToModelMessages to convert chunks to ModelMessages
|
||||
const modelMessages = await accumulator.toModelMessages()
|
||||
// Get the last message (should be the assistant's response)
|
||||
const modelMessage =
|
||||
modelMessages.length > 0 ? modelMessages[modelMessages.length - 1] : accumulator.toModelMessage('assistant')
|
||||
|
||||
const insertData: InsertSessionMessageRow = {
|
||||
session_id: sessionId,
|
||||
role: 'assistant',
|
||||
content: JSON.stringify(modelMessage),
|
||||
agent_session_id: agentSessionId,
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
}
|
||||
|
||||
const [saved] = await tx.insert(sessionMessagesTable).values(insertData).returning()
|
||||
logger.debug('Success Persisted session message')
|
||||
|
||||
return this.deserializeSessionMessage(saved) as AgentSessionMessageEntity
|
||||
} catch (error) {
|
||||
logger.error('Failed to persist session message', { error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private deserializeSessionMessage(data: any): AgentSessionMessageEntity {
|
||||
if (!data) return data
|
||||
|
||||
|
||||
@ -1,384 +0,0 @@
|
||||
AI SDK UI functions such as `useChat` and `useCompletion` support both text streams and data streams. The stream protocol defines how the data is streamed to the frontend on top of the HTTP protocol.
|
||||
|
||||
This page describes both protocols and how to use them in the backend and frontend.
|
||||
|
||||
You can use this information to develop custom backends and frontends for your use case, e.g., to provide compatible API endpoints that are implemented in a different language such as Python.
|
||||
|
||||
For instance, here's an example using [FastAPI](https://github.com/vercel/ai/tree/main/examples/next-fastapi) as a backend.
|
||||
|
||||
## Text Stream Protocol
|
||||
|
||||
A text stream contains chunks in plain text, that are streamed to the frontend. Each chunk is then appended together to form a full text response.
|
||||
|
||||
Text streams are supported by `useChat`, `useCompletion`, and `useObject`. When you use `useChat` or `useCompletion`, you need to enable text streaming by setting the `streamProtocol` options to `text`.
|
||||
|
||||
You can generate text streams with `streamText` in the backend. When you call `toTextStreamResponse()` on the result object, a streaming HTTP response is returned.
|
||||
|
||||
Text streams only support basic text data. If you need to stream other types of data such as tool calls, use data streams.
|
||||
|
||||
### Text Stream Example
|
||||
|
||||
Here is a Next.js example that uses the text stream protocol:
|
||||
|
||||
app/page.tsx
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { TextStreamChatTransport } from 'ai';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function Chat() {
|
||||
const [input, setInput] = useState('');
|
||||
const { messages, sendMessage } = useChat({
|
||||
transport: new TextStreamChatTransport({ api: '/api/chat' }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
|
||||
{messages.map(message => (
|
||||
<div key={message.id} className="whitespace-pre-wrap">
|
||||
{message.role === 'user' ? 'User: ' : 'AI: '}
|
||||
{message.parts.map((part, i) => {
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return <div key={\`${message.id}-${i}\`}>{part.text}</div>;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
sendMessage({ text: input });
|
||||
setInput('');
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="fixed dark:bg-zinc-900 bottom-0 w-full max-w-md p-2 mb-8 border border-zinc-300 dark:border-zinc-800 rounded shadow-xl"
|
||||
value={input}
|
||||
placeholder="Say something..."
|
||||
onChange={e => setInput(e.currentTarget.value)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Data Stream Protocol
|
||||
|
||||
A data stream follows a special protocol that the AI SDK provides to send information to the frontend.
|
||||
|
||||
The data stream protocol uses Server-Sent Events (SSE) format for improved standardization, keep-alive through ping, reconnect capabilities, and better cache handling.
|
||||
|
||||
The following stream parts are currently supported:
|
||||
|
||||
### Message Start Part
|
||||
|
||||
Indicates the beginning of a new message with metadata.
|
||||
|
||||
Format: Server-Sent Event with JSON object
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
data: {"type":"start","messageId":"..."}
|
||||
```
|
||||
|
||||
### Text Parts
|
||||
|
||||
Text content is streamed using a start/delta/end pattern with unique IDs for each text block.
|
||||
|
||||
#### Text Start Part
|
||||
|
||||
Indicates the beginning of a text block.
|
||||
|
||||
Format: Server-Sent Event with JSON object
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
data: {"type":"text-start","id":"msg_68679a454370819ca74c8eb3d04379630dd1afb72306ca5d"}
|
||||
```
|
||||
|
||||
#### Text Delta Part
|
||||
|
||||
Contains incremental text content for the text block.
|
||||
|
||||
Format: Server-Sent Event with JSON object
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
data: {"type":"text-delta","id":"msg_68679a454370819ca74c8eb3d04379630dd1afb72306ca5d","delta":"Hello"}
|
||||
```
|
||||
|
||||
#### Text End Part
|
||||
|
||||
Indicates the completion of a text block.
|
||||
|
||||
Format: Server-Sent Event with JSON object
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
data: {"type":"text-end","id":"msg_68679a454370819ca74c8eb3d04379630dd1afb72306ca5d"}
|
||||
```
|
||||
|
||||
### Reasoning Parts
|
||||
|
||||
Reasoning content is streamed using a start/delta/end pattern with unique IDs for each reasoning block.
|
||||
|
||||
#### Reasoning Start Part
|
||||
|
||||
Indicates the beginning of a reasoning block.
|
||||
|
||||
Format: Server-Sent Event with JSON object
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
data: {"type":"reasoning-start","id":"reasoning_123"}
|
||||
```
|
||||
|
||||
#### Reasoning Delta Part
|
||||
|
||||
Contains incremental reasoning content for the reasoning block.
|
||||
|
||||
Format: Server-Sent Event with JSON object
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
data: {"type":"reasoning-delta","id":"reasoning_123","delta":"This is some reasoning"}
|
||||
```
|
||||
|
||||
#### Reasoning End Part
|
||||
|
||||
Indicates the completion of a reasoning block.
|
||||
|
||||
Format: Server-Sent Event with JSON object
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
data: {"type":"reasoning-end","id":"reasoning_123"}
|
||||
```
|
||||
|
||||
### Source Parts
|
||||
|
||||
Source parts provide references to external content sources.
|
||||
|
||||
#### Source URL Part
|
||||
|
||||
References to external URLs.
|
||||
|
||||
Format: Server-Sent Event with JSON object
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
data: {"type":"source-url","sourceId":"https://example.com","url":"https://example.com"}
|
||||
```
|
||||
|
||||
#### Source Document Part
|
||||
|
||||
References to documents or files.
|
||||
|
||||
Format: Server-Sent Event with JSON object
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
data: {"type":"source-document","sourceId":"https://example.com","mediaType":"file","title":"Title"}
|
||||
```
|
||||
|
||||
### File Part
|
||||
|
||||
The file parts contain references to files with their media type.
|
||||
|
||||
Format: Server-Sent Event with JSON object
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
data: {"type":"file","url":"https://example.com/file.png","mediaType":"image/png"}
|
||||
```
|
||||
|
||||
### Data Parts
|
||||
|
||||
Custom data parts allow streaming of arbitrary structured data with type-specific handling.
|
||||
|
||||
Format: Server-Sent Event with JSON object where the type includes a custom suffix
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
data: {"type":"data-weather","data":{"location":"SF","temperature":100}}
|
||||
```
|
||||
|
||||
The `data-*` type pattern allows you to define custom data types that your frontend can handle specifically.
|
||||
|
||||
The error parts are appended to the message as they are received.
|
||||
|
||||
Format: Server-Sent Event with JSON object
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
data: {"type":"error","errorText":"error message"}
|
||||
```
|
||||
|
||||
### Tool Input Start Part
|
||||
|
||||
Indicates the beginning of tool input streaming.
|
||||
|
||||
Format: Server-Sent Event with JSON object
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
data: {"type":"tool-input-start","toolCallId":"call_fJdQDqnXeGxTmr4E3YPSR7Ar","toolName":"getWeatherInformation"}
|
||||
```
|
||||
|
||||
### Tool Input Delta Part
|
||||
|
||||
Incremental chunks of tool input as it's being generated.
|
||||
|
||||
Format: Server-Sent Event with JSON object
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
data: {"type":"tool-input-delta","toolCallId":"call_fJdQDqnXeGxTmr4E3YPSR7Ar","inputTextDelta":"San Francisco"}
|
||||
```
|
||||
|
||||
### Tool Input Available Part
|
||||
|
||||
Indicates that tool input is complete and ready for execution.
|
||||
|
||||
Format: Server-Sent Event with JSON object
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
data: {"type":"tool-input-available","toolCallId":"call_fJdQDqnXeGxTmr4E3YPSR7Ar","toolName":"getWeatherInformation","input":{"city":"San Francisco"}}
|
||||
```
|
||||
|
||||
### Tool Output Available Part
|
||||
|
||||
Contains the result of tool execution.
|
||||
|
||||
Format: Server-Sent Event with JSON object
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
data: {"type":"tool-output-available","toolCallId":"call_fJdQDqnXeGxTmr4E3YPSR7Ar","output":{"city":"San Francisco","weather":"sunny"}}
|
||||
```
|
||||
|
||||
### Start Step Part
|
||||
|
||||
A part indicating the start of a step.
|
||||
|
||||
Format: Server-Sent Event with JSON object
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
data: {"type":"start-step"}
|
||||
```
|
||||
|
||||
### Finish Step Part
|
||||
|
||||
A part indicating that a step (i.e., one LLM API call in the backend) has been completed.
|
||||
|
||||
This part is necessary to correctly process multiple stitched assistant calls, e.g. when calling tools in the backend, and using steps in `useChat` at the same time.
|
||||
|
||||
Format: Server-Sent Event with JSON object
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
data: {"type":"finish-step"}
|
||||
```
|
||||
|
||||
### Finish Message Part
|
||||
|
||||
A part indicating the completion of a message.
|
||||
|
||||
Format: Server-Sent Event with JSON object
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
data: {"type":"finish"}
|
||||
```
|
||||
|
||||
### Stream Termination
|
||||
|
||||
The stream ends with a special `[DONE]` marker.
|
||||
|
||||
Format: Server-Sent Event with literal `[DONE]`
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
The data stream protocol is supported by `useChat` and `useCompletion` on the frontend and used by default.`useCompletion` only supports the `text` and `data` stream parts.
|
||||
|
||||
On the backend, you can use `toUIMessageStreamResponse()` from the `streamText` result object to return a streaming HTTP response.
|
||||
|
||||
### UI Message Stream Example
|
||||
|
||||
Here is a Next.js example that uses the UI message stream protocol:
|
||||
|
||||
app/page.tsx
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function Chat() {
|
||||
const [input, setInput] = useState('');
|
||||
const { messages, sendMessage } = useChat();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
|
||||
{messages.map(message => (
|
||||
<div key={message.id} className="whitespace-pre-wrap">
|
||||
{message.role === 'user' ? 'User: ' : 'AI: '}
|
||||
{message.parts.map((part, i) => {
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return <div key={\`${message.id}-${i}\`}>{part.text}</div>;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
sendMessage({ text: input });
|
||||
setInput('');
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="fixed dark:bg-zinc-900 bottom-0 w-full max-w-md p-2 mb-8 border border-zinc-300 dark:border-zinc-800 rounded shadow-xl"
|
||||
value={input}
|
||||
placeholder="Say something..."
|
||||
onChange={e => setInput(e.currentTarget.value)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
@ -9,7 +9,7 @@ import { validateModelId } from '@main/apiServer/utils'
|
||||
|
||||
import { GetAgentSessionResponse } from '../..'
|
||||
import { AgentServiceInterface, AgentStream, AgentStreamEvent } from '../../interfaces/AgentStreamInterface'
|
||||
import { transformSDKMessageToUIChunk } from './transform'
|
||||
import { transformSDKMessageToStreamParts } from './transform'
|
||||
|
||||
const require_ = createRequire(import.meta.url)
|
||||
const logger = loggerService.withContext('ClaudeCodeService')
|
||||
@ -157,7 +157,7 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
}
|
||||
|
||||
// Transform SDKMessage to UIMessageChunks
|
||||
const chunks = transformSDKMessageToUIChunk(message)
|
||||
const chunks = transformSDKMessageToStreamParts(message)
|
||||
for (const chunk of chunks) {
|
||||
stream.emit('data', {
|
||||
type: 'chunk',
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
// ported from https://github.com/ben-vargas/ai-sdk-provider-claude-code/blob/main/src/map-claude-code-finish-reason.ts#L22
|
||||
import type { LanguageModelV2FinishReason } from '@ai-sdk/provider'
|
||||
|
||||
/**
|
||||
* Maps Claude Code SDK result subtypes to AI SDK finish reasons.
|
||||
*
|
||||
* @param subtype - The result subtype from Claude Code SDK
|
||||
* @returns The corresponding AI SDK finish reason
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const finishReason = mapClaudeCodeFinishReason('error_max_turns');
|
||||
* // Returns: 'length'
|
||||
* ```
|
||||
*
|
||||
* @remarks
|
||||
* Mappings:
|
||||
* - 'success' -> 'stop' (normal completion)
|
||||
* - 'error_max_turns' -> 'length' (hit turn limit)
|
||||
* - 'error_during_execution' -> 'error' (execution error)
|
||||
* - default -> 'stop' (unknown subtypes treated as normal completion)
|
||||
*/
|
||||
export function mapClaudeCodeFinishReason(subtype?: string): LanguageModelV2FinishReason {
|
||||
switch (subtype) {
|
||||
case 'success':
|
||||
return 'stop'
|
||||
case 'error_max_turns':
|
||||
return 'length'
|
||||
case 'error_during_execution':
|
||||
return 'error'
|
||||
default:
|
||||
return 'stop'
|
||||
}
|
||||
}
|
||||
@ -1,21 +1,34 @@
|
||||
// This file is used to transform claude code json response to aisdk streaming format
|
||||
|
||||
import type { LanguageModelV2Usage } from '@ai-sdk/provider'
|
||||
import { SDKMessage } from '@anthropic-ai/claude-code'
|
||||
import { loggerService } from '@logger'
|
||||
import { ProviderMetadata, UIMessageChunk } from 'ai'
|
||||
import type { ProviderMetadata, TextStreamPart } from 'ai'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { mapClaudeCodeFinishReason } from './map-claude-code-finish-reason'
|
||||
|
||||
const logger = loggerService.withContext('ClaudeCodeTransform')
|
||||
|
||||
type AgentStreamPart = TextStreamPart<Record<string, any>>
|
||||
|
||||
const contentBlockState = new Map<
|
||||
string,
|
||||
{
|
||||
type: 'text' | 'tool-call'
|
||||
toolCallId?: string
|
||||
toolName?: string
|
||||
input?: string
|
||||
}
|
||||
>()
|
||||
|
||||
// Helper function to generate unique IDs for text blocks
|
||||
const generateMessageId = (): string => {
|
||||
return `msg_${uuidv4().replace(/-/g, '')}`
|
||||
}
|
||||
const generateMessageId = (): string => `msg_${uuidv4().replace(/-/g, '')}`
|
||||
|
||||
// Main transform function
|
||||
export function transformSDKMessageToUIChunk(sdkMessage: SDKMessage): UIMessageChunk[] {
|
||||
const chunks: UIMessageChunk[] = []
|
||||
|
||||
export function transformSDKMessageToStreamParts(sdkMessage: SDKMessage): AgentStreamPart[] {
|
||||
const chunks: AgentStreamPart[] = []
|
||||
logger.debug('Transforming SDKMessage to stream parts', sdkMessage)
|
||||
switch (sdkMessage.type) {
|
||||
case 'assistant':
|
||||
case 'user':
|
||||
@ -35,7 +48,6 @@ export function transformSDKMessageToUIChunk(sdkMessage: SDKMessage): UIMessageC
|
||||
break
|
||||
|
||||
default:
|
||||
// Handle unknown message types gracefully
|
||||
logger.warn('Unknown SDKMessage type:', { type: (sdkMessage as any).type })
|
||||
break
|
||||
}
|
||||
@ -43,36 +55,45 @@ export function transformSDKMessageToUIChunk(sdkMessage: SDKMessage): UIMessageC
|
||||
return chunks
|
||||
}
|
||||
|
||||
function sdkMessageToProviderMetadata(message: SDKMessage): ProviderMetadata {
|
||||
const meta: ProviderMetadata = {
|
||||
message: message as Record<string, any>
|
||||
const sdkMessageToProviderMetadata = (message: SDKMessage): ProviderMetadata => {
|
||||
return {
|
||||
anthropic: {
|
||||
uuid: message.uuid || generateMessageId(),
|
||||
session_id: message.session_id
|
||||
},
|
||||
raw: message as Record<string, any>
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
function generateTextChunks(id: string, text: string, message: SDKMessage): UIMessageChunk[] {
|
||||
function generateTextChunks(id: string, text: string, message: SDKMessage): AgentStreamPart[] {
|
||||
const providerMetadata = sdkMessageToProviderMetadata(message)
|
||||
return [
|
||||
{
|
||||
type: 'text-start',
|
||||
id
|
||||
id,
|
||||
providerMetadata
|
||||
},
|
||||
{
|
||||
type: 'text-delta',
|
||||
id,
|
||||
delta: text
|
||||
text,
|
||||
providerMetadata
|
||||
},
|
||||
{
|
||||
type: 'text-end',
|
||||
id,
|
||||
providerMetadata: {
|
||||
rawMessage: sdkMessageToProviderMetadata(message)
|
||||
...providerMetadata,
|
||||
text: {
|
||||
value: text
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function handleUserOrAssistantMessage(message: Extract<SDKMessage, { type: 'assistant' | 'user' }>): UIMessageChunk[] {
|
||||
const chunks: UIMessageChunk[] = []
|
||||
function handleUserOrAssistantMessage(message: Extract<SDKMessage, { type: 'assistant' | 'user' }>): AgentStreamPart[] {
|
||||
const chunks: AgentStreamPart[] = []
|
||||
const messageId = message.uuid?.toString() || generateMessageId()
|
||||
|
||||
// handle normal text content
|
||||
@ -89,29 +110,25 @@ function handleUserOrAssistantMessage(message: Extract<SDKMessage, { type: 'assi
|
||||
break
|
||||
case 'tool_use':
|
||||
chunks.push({
|
||||
type: 'tool-input-available',
|
||||
type: 'tool-call',
|
||||
toolCallId: block.id,
|
||||
toolName: block.name,
|
||||
input: block.input,
|
||||
providerExecuted: true,
|
||||
providerMetadata: {
|
||||
rawMessage: sdkMessageToProviderMetadata(message)
|
||||
}
|
||||
providerMetadata: sdkMessageToProviderMetadata(message)
|
||||
})
|
||||
break
|
||||
case 'tool_result':
|
||||
chunks.push({
|
||||
type: 'tool-output-available',
|
||||
toolCallId: block.tool_use_id,
|
||||
output: block.content,
|
||||
providerExecuted: true,
|
||||
dynamic: false,
|
||||
preliminary: false
|
||||
})
|
||||
// chunks.push({
|
||||
// type: 'tool-result',
|
||||
// toolCallId: block.tool_use_id,
|
||||
// output: block.content,
|
||||
// providerMetadata: sdkMessageToProviderMetadata(message)
|
||||
// })
|
||||
break
|
||||
default:
|
||||
logger.warn('Unknown content block type in user/assistant message:', {
|
||||
type: (block as any).type
|
||||
type: block.type
|
||||
})
|
||||
break
|
||||
}
|
||||
@ -122,9 +139,10 @@ function handleUserOrAssistantMessage(message: Extract<SDKMessage, { type: 'assi
|
||||
}
|
||||
|
||||
// Handle stream events (real-time streaming)
|
||||
function handleStreamEvent(message: Extract<SDKMessage, { type: 'stream_event' }>): UIMessageChunk[] {
|
||||
const chunks: UIMessageChunk[] = []
|
||||
function handleStreamEvent(message: Extract<SDKMessage, { type: 'stream_event' }>): AgentStreamPart[] {
|
||||
const chunks: AgentStreamPart[] = []
|
||||
const event = message.event
|
||||
const blockKey = `${message.uuid ?? message.session_id ?? 'session'}:${event.index}`
|
||||
|
||||
switch (event.type) {
|
||||
case 'message_start':
|
||||
@ -132,69 +150,110 @@ function handleStreamEvent(message: Extract<SDKMessage, { type: 'stream_event' }
|
||||
break
|
||||
|
||||
case 'content_block_start':
|
||||
if (event.content_block?.type === 'text') {
|
||||
chunks.push({
|
||||
type: 'text-start',
|
||||
id: event.index?.toString() || generateMessageId(),
|
||||
providerMetadata: {
|
||||
anthropic: {
|
||||
uuid: message.uuid,
|
||||
session_id: message.session_id,
|
||||
content_block_index: event.index
|
||||
},
|
||||
raw: sdkMessageToProviderMetadata(message)
|
||||
}
|
||||
})
|
||||
} else if (event.content_block?.type === 'tool_use') {
|
||||
chunks.push({
|
||||
type: 'tool-input-start',
|
||||
toolCallId: event.content_block.id,
|
||||
toolName: event.content_block.name,
|
||||
providerExecuted: true
|
||||
})
|
||||
const contentBlockType = event.content_block.type
|
||||
switch (contentBlockType) {
|
||||
case 'text': {
|
||||
contentBlockState.set(blockKey, { type: 'text' })
|
||||
chunks.push({
|
||||
type: 'text-start',
|
||||
id: String(event.index),
|
||||
providerMetadata: {
|
||||
...sdkMessageToProviderMetadata(message),
|
||||
anthropic: {
|
||||
uuid: message.uuid,
|
||||
session_id: message.session_id,
|
||||
content_block_index: event.index
|
||||
}
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'tool_use': {
|
||||
contentBlockState.set(blockKey, {
|
||||
type: 'tool-call',
|
||||
toolCallId: event.content_block.id,
|
||||
toolName: event.content_block.name,
|
||||
input: ''
|
||||
})
|
||||
chunks.push({
|
||||
type: 'tool-call',
|
||||
toolCallId: event.content_block.id,
|
||||
toolName: event.content_block.name,
|
||||
input: event.content_block.input,
|
||||
providerExecuted: true,
|
||||
providerMetadata: sdkMessageToProviderMetadata(message)
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'content_block_delta':
|
||||
if (event.delta?.type === 'text_delta') {
|
||||
chunks.push({
|
||||
type: 'text-delta',
|
||||
id: event.index?.toString() || generateMessageId(),
|
||||
delta: event.delta.text,
|
||||
providerMetadata: {
|
||||
anthropic: {
|
||||
uuid: message.uuid,
|
||||
session_id: message.session_id,
|
||||
content_block_index: event.index
|
||||
},
|
||||
raw: sdkMessageToProviderMetadata(message)
|
||||
switch (event.delta.type) {
|
||||
case 'text_delta': {
|
||||
chunks.push({
|
||||
type: 'text-delta',
|
||||
id: String(event.index),
|
||||
text: event.delta.text,
|
||||
providerMetadata: {
|
||||
...sdkMessageToProviderMetadata(message),
|
||||
anthropic: {
|
||||
uuid: message.uuid,
|
||||
session_id: message.session_id,
|
||||
content_block_index: event.index
|
||||
}
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
// case 'thinking_delta': {
|
||||
// chunks.push({
|
||||
// type: 'reasoning-delta',
|
||||
// id: String(event.index),
|
||||
// text: event.delta.thinking,
|
||||
// });
|
||||
// break
|
||||
// }
|
||||
// case 'signature_delta': {
|
||||
// if (blockType === 'thinking') {
|
||||
// chunks.push({
|
||||
// type: 'reasoning-delta',
|
||||
// id: String(event.index),
|
||||
// text: '',
|
||||
// providerMetadata: {
|
||||
// ...sdkMessageToProviderMetadata(message),
|
||||
// anthropic: {
|
||||
// uuid: message.uuid,
|
||||
// session_id: message.session_id,
|
||||
// content_block_index: event.index,
|
||||
// signature: event.delta.signature
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// break
|
||||
// }
|
||||
case 'input_json_delta': {
|
||||
const contentBlock = contentBlockState.get(blockKey)
|
||||
if (contentBlock && contentBlock.type === 'tool-call') {
|
||||
contentBlockState.set(blockKey, {
|
||||
...contentBlock,
|
||||
input: `${contentBlock.input ?? ''}${event.delta.partial_json ?? ''}`
|
||||
})
|
||||
}
|
||||
})
|
||||
} else if (event.delta?.type === 'input_json_delta') {
|
||||
chunks.push({
|
||||
type: 'tool-input-delta',
|
||||
toolCallId: (event as any).content_block?.id || '',
|
||||
inputTextDelta: event.delta.partial_json
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'content_block_stop': {
|
||||
// Determine if this was a text block or tool use block
|
||||
const blockId = event.index?.toString() || generateMessageId()
|
||||
chunks.push({
|
||||
type: 'text-end',
|
||||
id: blockId,
|
||||
providerMetadata: {
|
||||
anthropic: {
|
||||
uuid: message.uuid,
|
||||
session_id: message.session_id,
|
||||
content_block_index: event.index
|
||||
},
|
||||
raw: sdkMessageToProviderMetadata(message)
|
||||
}
|
||||
})
|
||||
break
|
||||
const contentBlock = contentBlockState.get(blockKey)
|
||||
if (contentBlock?.type === 'text') {
|
||||
chunks.push({
|
||||
type: 'text-end',
|
||||
id: String(event.index)
|
||||
})
|
||||
}
|
||||
contentBlockState.delete(blockKey)
|
||||
}
|
||||
|
||||
case 'message_delta':
|
||||
@ -214,80 +273,68 @@ function handleStreamEvent(message: Extract<SDKMessage, { type: 'stream_event' }
|
||||
}
|
||||
|
||||
// Handle system messages
|
||||
function handleSystemMessage(message: Extract<SDKMessage, { type: 'system' }>): UIMessageChunk[] {
|
||||
const chunks: UIMessageChunk[] = []
|
||||
|
||||
if (message.subtype === 'init') {
|
||||
chunks.push({
|
||||
type: 'start',
|
||||
messageId: message.session_id
|
||||
})
|
||||
|
||||
// System initialization - could emit as a data chunk or skip
|
||||
chunks.push({
|
||||
type: 'data-system' as any,
|
||||
data: {
|
||||
type: 'init',
|
||||
session_id: message.session_id,
|
||||
raw: message
|
||||
}
|
||||
})
|
||||
} else if (message.subtype === 'compact_boundary') {
|
||||
chunks.push({
|
||||
type: 'data-system' as any,
|
||||
data: {
|
||||
type: 'compact_boundary',
|
||||
metadata: message.compact_metadata,
|
||||
raw: message
|
||||
}
|
||||
})
|
||||
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({
|
||||
type: 'start'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return chunks
|
||||
return []
|
||||
}
|
||||
|
||||
// Handle result messages (completion with usage stats)
|
||||
function handleResultMessage(message: Extract<SDKMessage, { type: 'result' }>): UIMessageChunk[] {
|
||||
const chunks: UIMessageChunk[] = []
|
||||
function handleResultMessage(message: Extract<SDKMessage, { type: 'result' }>): AgentStreamPart[] {
|
||||
const chunks: AgentStreamPart[] = []
|
||||
|
||||
const messageId = message.uuid
|
||||
let usage: LanguageModelV2Usage | undefined
|
||||
if ('usage' in message) {
|
||||
usage = {
|
||||
inputTokens:
|
||||
(message.usage.cache_creation_input_tokens ?? 0) +
|
||||
(message.usage.cache_read_input_tokens ?? 0) +
|
||||
(message.usage.input_tokens ?? 0),
|
||||
outputTokens: message.usage.output_tokens ?? 0,
|
||||
totalTokens:
|
||||
(message.usage.cache_creation_input_tokens ?? 0) +
|
||||
(message.usage.cache_read_input_tokens ?? 0) +
|
||||
(message.usage.input_tokens ?? 0) +
|
||||
(message.usage.output_tokens ?? 0)
|
||||
}
|
||||
}
|
||||
if (message.subtype === 'success') {
|
||||
// Emit final result data
|
||||
chunks.push({
|
||||
type: 'data-result' as any,
|
||||
id: messageId,
|
||||
data: message,
|
||||
transient: true
|
||||
})
|
||||
type: 'finish',
|
||||
totalUsage: usage,
|
||||
finishReason: mapClaudeCodeFinishReason(message.subtype),
|
||||
providerMetadata: {
|
||||
...sdkMessageToProviderMetadata(message),
|
||||
usage: message.usage,
|
||||
durationMs: message.duration_ms,
|
||||
costUsd: message.total_cost_usd,
|
||||
raw: message
|
||||
}
|
||||
} as AgentStreamPart)
|
||||
} else {
|
||||
// Handle error cases
|
||||
chunks.push({
|
||||
type: 'error',
|
||||
errorText: `${message.subtype}: Process failed after ${message.num_turns} turns`
|
||||
})
|
||||
}
|
||||
|
||||
// Emit usage and cost data
|
||||
chunks.push({
|
||||
type: 'data-usage' as any,
|
||||
data: {
|
||||
cost: message.total_cost_usd,
|
||||
usage: {
|
||||
input_tokens: message.usage.input_tokens,
|
||||
cache_creation_input_tokens: message.usage.cache_creation_input_tokens,
|
||||
cache_read_input_tokens: message.usage.cache_read_input_tokens,
|
||||
output_tokens: message.usage.output_tokens,
|
||||
service_tier: 'standard'
|
||||
error: {
|
||||
message: `${message.subtype}: Process failed after ${message.num_turns} turns`
|
||||
}
|
||||
}
|
||||
})
|
||||
} as AgentStreamPart)
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
// Convenience function to transform a stream of SDKMessages
|
||||
export function* transformSDKMessageStream(sdkMessages: SDKMessage[]): Generator<UIMessageChunk> {
|
||||
export function* transformSDKMessageStream(sdkMessages: SDKMessage[]): Generator<AgentStreamPart> {
|
||||
for (const sdkMessage of sdkMessages) {
|
||||
const chunks = transformSDKMessageToUIChunk(sdkMessage)
|
||||
const chunks = transformSDKMessageToStreamParts(sdkMessage)
|
||||
for (const chunk of chunks) {
|
||||
yield chunk
|
||||
}
|
||||
@ -297,9 +344,9 @@ export function* transformSDKMessageStream(sdkMessages: SDKMessage[]): Generator
|
||||
// Async version for async iterables
|
||||
export async function* transformSDKMessageStreamAsync(
|
||||
sdkMessages: AsyncIterable<SDKMessage>
|
||||
): AsyncGenerator<UIMessageChunk> {
|
||||
): AsyncGenerator<AgentStreamPart> {
|
||||
for await (const sdkMessage of sdkMessages) {
|
||||
const chunks = transformSDKMessageToUIChunk(sdkMessage)
|
||||
const chunks = transformSDKMessageToStreamParts(sdkMessage)
|
||||
for (const chunk of chunks) {
|
||||
yield chunk
|
||||
}
|
||||
|
||||
@ -32,16 +32,19 @@ export class AiSdkToChunkAdapter {
|
||||
private accumulate: boolean | undefined
|
||||
private isFirstChunk = true
|
||||
private enableWebSearch: boolean = false
|
||||
private onSessionUpdate?: (sessionId: string) => void
|
||||
|
||||
constructor(
|
||||
private onChunk: (chunk: Chunk) => void,
|
||||
mcpTools: MCPTool[] = [],
|
||||
accumulate?: boolean,
|
||||
enableWebSearch?: boolean
|
||||
enableWebSearch?: boolean,
|
||||
onSessionUpdate?: (sessionId: string) => void
|
||||
) {
|
||||
this.toolCallHandler = new ToolCallChunkHandler(onChunk, mcpTools)
|
||||
this.accumulate = accumulate
|
||||
this.enableWebSearch = enableWebSearch || false
|
||||
this.onSessionUpdate = onSessionUpdate
|
||||
}
|
||||
|
||||
/**
|
||||
@ -108,6 +111,15 @@ export class AiSdkToChunkAdapter {
|
||||
chunk: TextStreamPart<any>,
|
||||
final: { text: string; reasoningContent: string; webSearchResults: AISDKWebSearchResult[]; reasoningId: string }
|
||||
) {
|
||||
const sessionId =
|
||||
(chunk.providerMetadata as any)?.anthropic?.session_id ??
|
||||
(chunk.providerMetadata as any)?.anthropic?.sessionId ??
|
||||
(chunk.providerMetadata as any)?.raw?.session_id
|
||||
|
||||
if (typeof sessionId === 'string' && sessionId) {
|
||||
this.onSessionUpdate?.(sessionId)
|
||||
}
|
||||
|
||||
logger.silly(`AI SDK chunk type: ${chunk.type}`, chunk)
|
||||
switch (chunk.type) {
|
||||
// === 文本相关事件 ===
|
||||
|
||||
@ -10,7 +10,6 @@ import {
|
||||
CreateAgentResponse,
|
||||
CreateAgentResponseSchema,
|
||||
CreateSessionForm,
|
||||
CreateSessionMessageRequest,
|
||||
CreateSessionRequest,
|
||||
CreateSessionResponse,
|
||||
CreateSessionResponseSchema,
|
||||
@ -225,16 +224,6 @@ export class AgentApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
public async createMessage(agentId: string, sessionId: string, content: string): Promise<void> {
|
||||
const url = this.getSessionMessagesPath(agentId, sessionId)
|
||||
try {
|
||||
const payload = { content } satisfies CreateSessionMessageRequest
|
||||
await this.axios.post(url, payload)
|
||||
} catch (error) {
|
||||
throw processError(error, 'Failed to post message.')
|
||||
}
|
||||
}
|
||||
|
||||
public async getModels(props?: ApiModelsFilter): Promise<ApiModelsResponse> {
|
||||
const url = this.getModelsPath(props)
|
||||
try {
|
||||
|
||||
@ -158,6 +158,35 @@ export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, o
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const addAccessiblePath = useCallback(async () => {
|
||||
try {
|
||||
const selected = await window.api.file.selectFolder()
|
||||
if (!selected) {
|
||||
return
|
||||
}
|
||||
setForm((prev) => {
|
||||
if (prev.accessible_paths.includes(selected)) {
|
||||
window.toast.warning(t('agent.session.accessible_paths.duplicate'))
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
accessible_paths: [...prev.accessible_paths, selected]
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to select accessible path:', error as Error)
|
||||
window.toast.error(t('agent.session.accessible_paths.select_failed'))
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const removeAccessiblePath = useCallback((path: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
accessible_paths: prev.accessible_paths.filter((item) => item !== path)
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const modelOptions = useMemo(() => {
|
||||
// mocked data. not final version
|
||||
return (models ?? []).map((model) => ({
|
||||
@ -165,7 +194,8 @@ export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, o
|
||||
key: model.id,
|
||||
label: model.name,
|
||||
avatar: getModelLogo(model.id),
|
||||
providerId: model.provider
|
||||
providerId: model.provider,
|
||||
providerName: model.provider_name
|
||||
})) satisfies ModelOption[]
|
||||
}, [models])
|
||||
|
||||
@ -197,6 +227,12 @@ export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, o
|
||||
return
|
||||
}
|
||||
|
||||
if (form.accessible_paths.length === 0) {
|
||||
window.toast.error(t('agent.session.accessible_paths.required'))
|
||||
loadingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditing(agent)) {
|
||||
if (!agent) {
|
||||
throw new Error('Agent is required for editing mode')
|
||||
@ -207,7 +243,8 @@ export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, o
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
instructions: form.instructions,
|
||||
model: form.model
|
||||
model: form.model,
|
||||
accessible_paths: [...form.accessible_paths]
|
||||
} satisfies UpdateAgentForm
|
||||
|
||||
updateAgent(updatePayload)
|
||||
@ -309,6 +346,34 @@ export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, o
|
||||
value={form.description ?? ''}
|
||||
onValueChange={onDescChange}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t('agent.session.accessible_paths.label')}
|
||||
</span>
|
||||
<Button size="sm" variant="flat" onPress={addAccessiblePath}>
|
||||
{t('agent.session.accessible_paths.add')}
|
||||
</Button>
|
||||
</div>
|
||||
{form.accessible_paths.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{form.accessible_paths.map((path) => (
|
||||
<div
|
||||
key={path}
|
||||
className="flex items-center justify-between gap-2 rounded-medium border border-default-200 px-3 py-2">
|
||||
<span className="truncate text-sm" title={path}>
|
||||
{path}
|
||||
</span>
|
||||
<Button size="sm" variant="light" color="danger" onPress={() => removeAccessiblePath(path)}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-foreground-400">{t('agent.session.accessible_paths.empty')}</p>
|
||||
)}
|
||||
</div>
|
||||
<Textarea label={t('common.prompt')} value={form.instructions ?? ''} onValueChange={onInstChange} />
|
||||
</ModalBody>
|
||||
<ModalFooter className="w-full">
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
} from '@heroui/react'
|
||||
import { loggerService } from '@logger'
|
||||
import { getModelLogo } from '@renderer/config/models'
|
||||
import { useAgent } from '@renderer/hooks/agents/useAgent'
|
||||
import { useModels } from '@renderer/hooks/agents/useModels'
|
||||
import { useSessions } from '@renderer/hooks/agents/useSessions'
|
||||
import { AgentEntity, AgentSessionEntity, BaseSessionForm, CreateSessionForm, UpdateSessionForm } from '@renderer/types'
|
||||
@ -80,15 +81,16 @@ export const SessionModal: React.FC<Props> = ({ agentId, session, trigger, isOpe
|
||||
const { createSession, updateSession } = useSessions(agentId)
|
||||
// Only support claude code for now
|
||||
const { models } = useModels({ providerType: 'anthropic' })
|
||||
const { agent } = useAgent(agentId)
|
||||
const isEditing = (session?: AgentSessionEntity) => session !== undefined
|
||||
|
||||
const [form, setForm] = useState<BaseSessionForm>(() => buildSessionForm(session))
|
||||
const [form, setForm] = useState<BaseSessionForm>(() => buildSessionForm(session, agent ?? undefined))
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setForm(buildSessionForm(session))
|
||||
setForm(buildSessionForm(session, agent ?? undefined))
|
||||
}
|
||||
}, [session, isOpen])
|
||||
}, [session, agent, isOpen])
|
||||
|
||||
const Item = useCallback(({ item }: { item: SelectedItemProps<BaseOption> }) => <Option option={item.data} />, [])
|
||||
|
||||
@ -125,7 +127,8 @@ export const SessionModal: React.FC<Props> = ({ agentId, session, trigger, isOpe
|
||||
key: model.id,
|
||||
label: model.name,
|
||||
avatar: getModelLogo(model.id),
|
||||
providerId: model.provider
|
||||
providerId: model.provider,
|
||||
providerName: model.provider_name
|
||||
})) satisfies ModelOption[]
|
||||
}, [models])
|
||||
|
||||
@ -152,6 +155,12 @@ export const SessionModal: React.FC<Props> = ({ agentId, session, trigger, isOpe
|
||||
return
|
||||
}
|
||||
|
||||
if (form.accessible_paths.length === 0) {
|
||||
window.toast.error(t('agent.session.accessible_paths.required'))
|
||||
loadingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditing(session)) {
|
||||
if (!session) {
|
||||
throw new Error('Agent is required for editing mode')
|
||||
@ -162,7 +171,8 @@ export const SessionModal: React.FC<Props> = ({ agentId, session, trigger, isOpe
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
instructions: form.instructions,
|
||||
model: form.model
|
||||
model: form.model,
|
||||
accessible_paths: [...form.accessible_paths]
|
||||
} satisfies UpdateSessionForm
|
||||
|
||||
updateSession(updatePayload)
|
||||
@ -248,7 +258,6 @@ export const SessionModal: React.FC<Props> = ({ agentId, session, trigger, isOpe
|
||||
value={form.description ?? ''}
|
||||
onValueChange={onDescChange}
|
||||
/>
|
||||
{/* TODO: accessible paths */}
|
||||
<Textarea label={t('common.prompt')} value={form.instructions ?? ''} onValueChange={onInstChange} />
|
||||
</ModalBody>
|
||||
<ModalFooter className="w-full">
|
||||
|
||||
@ -12,6 +12,7 @@ export interface BaseOption {
|
||||
|
||||
export interface ModelOption extends BaseOption {
|
||||
providerId?: string
|
||||
providerName?: string
|
||||
}
|
||||
|
||||
export function isModelOption(option: BaseOption): option is ModelOption {
|
||||
@ -33,10 +34,18 @@ export const Option = ({ option }: { option?: BaseOption | null }) => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const providerLabel = (() => {
|
||||
if (!isModelOption(option)) return null
|
||||
if (option.providerName) return option.providerName
|
||||
if (option.providerId) return getProviderLabel(option.providerId)
|
||||
return null
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Avatar src={option.avatar} className="h-5 w-5" />
|
||||
{option.label} {isModelOption(option) && option.providerId && `| ${getProviderLabel(option.providerId)}`}
|
||||
<span className="truncate">{option.label}</span>
|
||||
{providerLabel ? <span className="truncate text-foreground-500">| {providerLabel}</span> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -9,15 +9,15 @@ import { useAgentClient } from './useAgentClient'
|
||||
export const useAgent = (id: string | null) => {
|
||||
const { t } = useTranslation()
|
||||
const client = useAgentClient()
|
||||
const key = client.agentPaths.base
|
||||
const key = id ? client.agentPaths.withId(id) : null
|
||||
const fetcher = useCallback(async () => {
|
||||
if (id === null) {
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
const result = await client.getAgent(id)
|
||||
return result
|
||||
}, [client, id])
|
||||
const { data, error, isLoading, mutate } = useSWR(key, fetcher)
|
||||
const { data, error, isLoading, mutate } = useSWR(key, id ? fetcher : null)
|
||||
|
||||
const updateAgent = useCallback(
|
||||
async (form: UpdateAgentForm) => {
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { AgentSessionMessageEntity, UpdateSessionForm } from '@renderer/types'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { useCallback } from 'react'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { removeManyBlocks,upsertManyBlocks } from '@renderer/store/messageBlock'
|
||||
import { newMessagesActions } from '@renderer/store/newMessage'
|
||||
import { AgentPersistedMessage, UpdateSessionForm } from '@renderer/types'
|
||||
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR from 'swr'
|
||||
|
||||
@ -10,6 +13,9 @@ export const useSession = (agentId: string, sessionId: string) => {
|
||||
const { t } = useTranslation()
|
||||
const client = useAgentClient()
|
||||
const key = client.getSessionPaths(agentId).withId(sessionId)
|
||||
const dispatch = useAppDispatch()
|
||||
const sessionTopicId = useMemo(() => buildAgentSessionTopicId(sessionId), [sessionId])
|
||||
const blockIdsRef = useRef<string[]>([])
|
||||
|
||||
const fetcher = async () => {
|
||||
const data = await client.getSession(agentId, sessionId)
|
||||
@ -17,6 +23,38 @@ export const useSession = (agentId: string, sessionId: string) => {
|
||||
}
|
||||
const { data, error, isLoading, mutate } = useSWR(key, fetcher)
|
||||
|
||||
useEffect(() => {
|
||||
const messages = data?.messages ?? []
|
||||
if (!messages.length) {
|
||||
dispatch(newMessagesActions.messagesReceived({ topicId: sessionTopicId, messages: [] }))
|
||||
blockIdsRef.current = []
|
||||
return
|
||||
}
|
||||
|
||||
const persistedEntries = messages
|
||||
.map((entity) => entity.content as AgentPersistedMessage | undefined)
|
||||
.filter((entry): entry is AgentPersistedMessage => Boolean(entry))
|
||||
|
||||
const allBlocks = persistedEntries.flatMap((entry) => entry.blocks)
|
||||
if (allBlocks.length > 0) {
|
||||
dispatch(upsertManyBlocks(allBlocks))
|
||||
}
|
||||
|
||||
blockIdsRef.current = allBlocks.map((block) => block.id)
|
||||
|
||||
const messageRecords = persistedEntries.map((entry) => entry.message)
|
||||
dispatch(newMessagesActions.messagesReceived({ topicId: sessionTopicId, messages: messageRecords }))
|
||||
}, [data?.messages, dispatch, sessionTopicId])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (blockIdsRef.current.length > 0) {
|
||||
dispatch(removeManyBlocks(blockIdsRef.current))
|
||||
}
|
||||
dispatch(newMessagesActions.clearTopicMessages(sessionTopicId))
|
||||
}
|
||||
}, [dispatch, sessionTopicId])
|
||||
|
||||
const updateSession = useCallback(
|
||||
async (form: UpdateSessionForm) => {
|
||||
if (!agentId) return
|
||||
@ -30,53 +68,11 @@ export const useSession = (agentId: string, sessionId: string) => {
|
||||
[agentId, client, mutate, t]
|
||||
)
|
||||
|
||||
const createSessionMessage = useCallback(
|
||||
async (content: string) => {
|
||||
if (!agentId || !sessionId || !data) return
|
||||
const origin = cloneDeep(data)
|
||||
const newMessageDraft = {
|
||||
id: 77777,
|
||||
session_id: '',
|
||||
role: 'user',
|
||||
content: {
|
||||
role: 'user',
|
||||
content: content,
|
||||
providerOptions: undefined
|
||||
},
|
||||
agent_session_id: '',
|
||||
created_at: '',
|
||||
updated_at: ''
|
||||
} satisfies AgentSessionMessageEntity
|
||||
try {
|
||||
mutate(
|
||||
(prev) => ({
|
||||
...prev,
|
||||
accessible_paths: prev?.accessible_paths ?? [],
|
||||
model: prev?.model ?? '',
|
||||
id: prev?.id ?? '',
|
||||
agent_id: prev?.id ?? '',
|
||||
agent_type: prev?.agent_type ?? 'claude-code',
|
||||
created_at: prev?.created_at ?? '',
|
||||
updated_at: prev?.updated_at ?? '',
|
||||
messages: [...(prev?.messages ?? []), newMessageDraft]
|
||||
}),
|
||||
false
|
||||
)
|
||||
await client.createMessage(agentId, sessionId, content)
|
||||
} catch (error) {
|
||||
mutate(origin)
|
||||
window.toast.error(t('common.errors.create_message'))
|
||||
}
|
||||
},
|
||||
[agentId, sessionId, data, mutate, client, t]
|
||||
)
|
||||
|
||||
return {
|
||||
session: data,
|
||||
messages: data?.messages ?? [],
|
||||
error,
|
||||
isLoading,
|
||||
updateSession,
|
||||
createSessionMessage
|
||||
mutate
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,6 +51,14 @@
|
||||
"error": {
|
||||
"failed": "Failed to update the session"
|
||||
}
|
||||
},
|
||||
"accessible_paths": {
|
||||
"label": "Accessible directories",
|
||||
"add": "Add directory",
|
||||
"empty": "Select at least one directory that the agent can access.",
|
||||
"required": "Please select at least one accessible directory.",
|
||||
"duplicate": "This directory is already included.",
|
||||
"select_failed": "Failed to select directory."
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
|
||||
@ -51,6 +51,14 @@
|
||||
"error": {
|
||||
"failed": "更新会话失败"
|
||||
}
|
||||
},
|
||||
"accessible_paths": {
|
||||
"label": "工作目录",
|
||||
"add": "添加目录",
|
||||
"empty": "请选择至少一个智能体可访问的目录。",
|
||||
"required": "请至少选择一个可访问的目录。",
|
||||
"duplicate": "该目录已添加。",
|
||||
"select_failed": "选择目录失败"
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"agent": {
|
||||
"add": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to add agent",
|
||||
"failed": "無法新增代理人",
|
||||
"invalid_agent": "無效的 Agent"
|
||||
},
|
||||
"title": "新增代理",
|
||||
@ -14,7 +14,7 @@
|
||||
"delete": {
|
||||
"content": "刪除該 Agent 將強制終止並刪除該 Agent 下的所有會話。您確定嗎?",
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to delete the agent"
|
||||
"failed": "刪除代理程式失敗"
|
||||
},
|
||||
"title": "刪除 Agent"
|
||||
},
|
||||
@ -23,39 +23,47 @@
|
||||
},
|
||||
"session": {
|
||||
"add": {
|
||||
"title": "[to be translated]:Add a session"
|
||||
"title": "新增會議"
|
||||
},
|
||||
"create": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to add a session"
|
||||
"failed": "無法新增工作階段"
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"content": "[to be translated]:Are you sure to delete this session?",
|
||||
"content": "您確定要刪除此工作階段嗎?",
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to delete the session"
|
||||
"failed": "無法刪除工作階段"
|
||||
},
|
||||
"title": "[to be translated]:Delete session"
|
||||
"title": "刪除工作階段"
|
||||
},
|
||||
"edit": {
|
||||
"title": "[to be translated]:Edit session"
|
||||
"title": "編輯工作階段"
|
||||
},
|
||||
"get": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to get the session"
|
||||
"failed": "無法取得工作階段"
|
||||
}
|
||||
},
|
||||
"label_one": "[to be translated]:Session",
|
||||
"label_other": "[to be translated]:Sessions",
|
||||
"label_one": "會議",
|
||||
"label_other": "Sessions",
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to update the session"
|
||||
"failed": "無法更新工作階段"
|
||||
}
|
||||
},
|
||||
"accessible_paths": {
|
||||
"label": "可存取的目錄",
|
||||
"add": "新增目錄",
|
||||
"empty": "選擇至少一個代理可以存取的目錄。",
|
||||
"required": "請至少選擇一個可存取的目錄。",
|
||||
"duplicate": "此目錄已包含在內。",
|
||||
"select_failed": "無法選擇目錄。"
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to update the agent"
|
||||
"failed": "無法更新代理程式"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -821,7 +829,7 @@
|
||||
"default": "預設",
|
||||
"delete": "刪除",
|
||||
"delete_confirm": "確定要刪除嗎?",
|
||||
"delete_failed": "[to be translated]:Failed to delete",
|
||||
"delete_failed": "刪除失敗",
|
||||
"delete_success": "刪除成功",
|
||||
"description": "描述",
|
||||
"detail": "詳情",
|
||||
@ -833,7 +841,7 @@
|
||||
"enabled": "已啟用",
|
||||
"error": "錯誤",
|
||||
"errors": {
|
||||
"create_message": "[to be translated]:Failed to create message",
|
||||
"create_message": "無法建立訊息",
|
||||
"validation": "驗證失敗"
|
||||
},
|
||||
"expand": "展開",
|
||||
@ -962,7 +970,7 @@
|
||||
"modelType": "模型類型",
|
||||
"name": "錯誤名稱",
|
||||
"no_api_key": "API 金鑰未設定",
|
||||
"no_response": "[to be translated]:No response",
|
||||
"no_response": "無回應",
|
||||
"originalError": "原錯誤",
|
||||
"originalMessage": "原消息",
|
||||
"parameter": "參數",
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"agent": {
|
||||
"add": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to add agent",
|
||||
"failed": "Αποτυχία προσθήκης πράκτορα",
|
||||
"invalid_agent": "Μη έγκυρος Agent"
|
||||
},
|
||||
"title": "Προσθήκη Agent",
|
||||
@ -14,7 +14,7 @@
|
||||
"delete": {
|
||||
"content": "Η διαγραφή αυτού του Agent θα τερματίσει βίαια και θα διαγράψει όλες τις συνεδρίες υπό αυτόν τον Agent. Είστε σίγουροι;",
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to delete the agent"
|
||||
"failed": "Αποτυχία διαγραφής του πράκτορα"
|
||||
},
|
||||
"title": "Διαγραφή Agent"
|
||||
},
|
||||
@ -23,39 +23,47 @@
|
||||
},
|
||||
"session": {
|
||||
"add": {
|
||||
"title": "[to be translated]:Add a session"
|
||||
"title": "Προσθήκη συνεδρίας"
|
||||
},
|
||||
"create": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to add a session"
|
||||
"failed": "Αποτυχία προσθήκης συνεδρίας"
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"content": "[to be translated]:Are you sure to delete this session?",
|
||||
"content": "Είσαι σίγουρος ότι θέλεις να διαγράψεις αυτήν τη συνεδρία;",
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to delete the session"
|
||||
"failed": "Αποτυχία διαγραφής της συνεδρίας"
|
||||
},
|
||||
"title": "[to be translated]:Delete session"
|
||||
"title": "Διαγραφή συνεδρίας"
|
||||
},
|
||||
"edit": {
|
||||
"title": "[to be translated]:Edit session"
|
||||
"title": "Συνεδρία επεξεργασίας"
|
||||
},
|
||||
"get": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to get the session"
|
||||
"failed": "Αποτυχία λήψης της συνεδρίας"
|
||||
}
|
||||
},
|
||||
"label_one": "[to be translated]:Session",
|
||||
"label_other": "[to be translated]:Sessions",
|
||||
"label_one": "Συνεδρία",
|
||||
"label_other": "Συνεδρίες",
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to update the session"
|
||||
"failed": "Αποτυχία ενημέρωσης της συνεδρίας"
|
||||
}
|
||||
},
|
||||
"accessible_paths": {
|
||||
"label": "Προσβάσιμοι κατάλογοι",
|
||||
"add": "Προσθήκη καταλόγου",
|
||||
"empty": "Επιλέξτε τουλάχιστον έναν κατάλογο στον οποίο ο πράκτορας μπορεί να έχει πρόσβαση.",
|
||||
"required": "Παρακαλώ επιλέξτε τουλάχιστον έναν προσβάσιμο κατάλογο.",
|
||||
"duplicate": "Αυτός ο κατάλογος έχει ήδη συμπεριληφθεί.",
|
||||
"select_failed": "Αποτυχία επιλογής καταλόγου."
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to update the agent"
|
||||
"failed": "Αποτυχία ενημέρωσης του πράκτορα"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -821,7 +829,7 @@
|
||||
"default": "Προεπιλογή",
|
||||
"delete": "Διαγραφή",
|
||||
"delete_confirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε;",
|
||||
"delete_failed": "[to be translated]:Failed to delete",
|
||||
"delete_failed": "Αποτυχία διαγραφής",
|
||||
"delete_success": "Η διαγραφή ήταν επιτυχής",
|
||||
"description": "Περιγραφή",
|
||||
"detail": "Λεπτομέρειες",
|
||||
@ -833,7 +841,7 @@
|
||||
"enabled": "Ενεργοποιημένο",
|
||||
"error": "σφάλμα",
|
||||
"errors": {
|
||||
"create_message": "[to be translated]:Failed to create message",
|
||||
"create_message": "Αποτυχία δημιουργίας μηνύματος",
|
||||
"validation": "Η επαλήθευση απέτυχε"
|
||||
},
|
||||
"expand": "Επεκτάση",
|
||||
@ -962,7 +970,7 @@
|
||||
"modelType": "Τύπος μοντέλου",
|
||||
"name": "Λάθος όνομα",
|
||||
"no_api_key": "Δεν έχετε ρυθμίσει το κλειδί API",
|
||||
"no_response": "[to be translated]:No response",
|
||||
"no_response": "Καμία απάντηση",
|
||||
"originalError": "Αρχικό σφάλμα",
|
||||
"originalMessage": "Αρχικό μήνυμα",
|
||||
"parameter": "παράμετροι",
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"agent": {
|
||||
"add": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to add agent",
|
||||
"failed": "Error al añadir agente",
|
||||
"invalid_agent": "Agent inválido"
|
||||
},
|
||||
"title": "Agregar Agente",
|
||||
@ -14,7 +14,7 @@
|
||||
"delete": {
|
||||
"content": "Eliminar este Agente forzará la terminación y eliminación de todas las sesiones bajo este Agente. ¿Está seguro?",
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to delete the agent"
|
||||
"failed": "Error al eliminar el agente"
|
||||
},
|
||||
"title": "Eliminar Agent"
|
||||
},
|
||||
@ -23,39 +23,47 @@
|
||||
},
|
||||
"session": {
|
||||
"add": {
|
||||
"title": "[to be translated]:Add a session"
|
||||
"title": "Agregar una sesión"
|
||||
},
|
||||
"create": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to add a session"
|
||||
"failed": "Error al añadir una sesión"
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"content": "[to be translated]:Are you sure to delete this session?",
|
||||
"content": "¿Estás seguro de eliminar esta sesión?",
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to delete the session"
|
||||
"failed": "Error al eliminar la sesión"
|
||||
},
|
||||
"title": "[to be translated]:Delete session"
|
||||
"title": "Eliminar sesión"
|
||||
},
|
||||
"edit": {
|
||||
"title": "[to be translated]:Edit session"
|
||||
"title": "Sesión de edición"
|
||||
},
|
||||
"get": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to get the session"
|
||||
"failed": "Error al obtener la sesión"
|
||||
}
|
||||
},
|
||||
"label_one": "[to be translated]:Session",
|
||||
"label_other": "[to be translated]:Sessions",
|
||||
"label_one": "Sesión",
|
||||
"label_other": "Sesiones",
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to update the session"
|
||||
"failed": "Error al actualizar la sesión"
|
||||
}
|
||||
},
|
||||
"accessible_paths": {
|
||||
"label": "Directorios accesibles",
|
||||
"add": "Agregar directorio",
|
||||
"empty": "Selecciona al menos un directorio al que el agente pueda acceder.",
|
||||
"required": "Por favor, seleccione al menos un directorio accesible.",
|
||||
"duplicate": "Este directorio ya está incluido.",
|
||||
"select_failed": "Error al seleccionar el directorio."
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to update the agent"
|
||||
"failed": "Error al actualizar el agente"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -821,7 +829,7 @@
|
||||
"default": "Predeterminado",
|
||||
"delete": "Eliminar",
|
||||
"delete_confirm": "¿Está seguro de que desea eliminarlo?",
|
||||
"delete_failed": "[to be translated]:Failed to delete",
|
||||
"delete_failed": "Error al eliminar",
|
||||
"delete_success": "Eliminación exitosa",
|
||||
"description": "Descripción",
|
||||
"detail": "Detalles",
|
||||
@ -833,7 +841,7 @@
|
||||
"enabled": "Activado",
|
||||
"error": "error",
|
||||
"errors": {
|
||||
"create_message": "[to be translated]:Failed to create message",
|
||||
"create_message": "Error al crear el mensaje",
|
||||
"validation": "Fallo en la verificación"
|
||||
},
|
||||
"expand": "Expandir",
|
||||
@ -962,7 +970,7 @@
|
||||
"modelType": "Tipo de modelo",
|
||||
"name": "Nombre de error",
|
||||
"no_api_key": "La clave API no está configurada",
|
||||
"no_response": "[to be translated]:No response",
|
||||
"no_response": "Sin respuesta",
|
||||
"originalError": "Error original",
|
||||
"originalMessage": "mensaje original",
|
||||
"parameter": "parámetro",
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"agent": {
|
||||
"add": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to add agent",
|
||||
"failed": "Échec de l'ajout de l'agent",
|
||||
"invalid_agent": "Agent invalide"
|
||||
},
|
||||
"title": "Ajouter un agent",
|
||||
@ -14,7 +14,7 @@
|
||||
"delete": {
|
||||
"content": "La suppression de cet Agent entraînera la terminaison forcée et la suppression de toutes les sessions associées. Êtes-vous certain ?",
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to delete the agent"
|
||||
"failed": "Échec de la suppression de l'agent"
|
||||
},
|
||||
"title": "Supprimer l'Agent"
|
||||
},
|
||||
@ -23,39 +23,47 @@
|
||||
},
|
||||
"session": {
|
||||
"add": {
|
||||
"title": "[to be translated]:Add a session"
|
||||
"title": "Ajouter une session"
|
||||
},
|
||||
"create": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to add a session"
|
||||
"failed": "Échec de l'ajout d'une session"
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"content": "[to be translated]:Are you sure to delete this session?",
|
||||
"content": "Êtes-vous sûr de vouloir supprimer cette session ?",
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to delete the session"
|
||||
"failed": "Échec de la suppression de la session"
|
||||
},
|
||||
"title": "[to be translated]:Delete session"
|
||||
"title": "Supprimer la session"
|
||||
},
|
||||
"edit": {
|
||||
"title": "[to be translated]:Edit session"
|
||||
"title": "Session d'édition"
|
||||
},
|
||||
"get": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to get the session"
|
||||
"failed": "Échec de l'obtention de la session"
|
||||
}
|
||||
},
|
||||
"label_one": "[to be translated]:Session",
|
||||
"label_other": "[to be translated]:Sessions",
|
||||
"label_one": "Session",
|
||||
"label_other": "Séances",
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to update the session"
|
||||
"failed": "Échec de la mise à jour de la session"
|
||||
}
|
||||
},
|
||||
"accessible_paths": {
|
||||
"label": "Répertoires accessibles",
|
||||
"add": "Ajouter un répertoire",
|
||||
"empty": "Sélectionnez au moins un répertoire auquel l'agent peut accéder.",
|
||||
"required": "Veuillez sélectionner au moins un répertoire accessible.",
|
||||
"duplicate": "Ce répertoire est déjà inclus.",
|
||||
"select_failed": "Échec de la sélection du répertoire."
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to update the agent"
|
||||
"failed": "Échec de la mise à jour de l'agent"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -821,7 +829,7 @@
|
||||
"default": "Défaut",
|
||||
"delete": "Supprimer",
|
||||
"delete_confirm": "Êtes-vous sûr de vouloir supprimer ?",
|
||||
"delete_failed": "[to be translated]:Failed to delete",
|
||||
"delete_failed": "Échec de la suppression",
|
||||
"delete_success": "Suppression réussie",
|
||||
"description": "Description",
|
||||
"detail": "détails",
|
||||
@ -833,7 +841,7 @@
|
||||
"enabled": "Activé",
|
||||
"error": "erreur",
|
||||
"errors": {
|
||||
"create_message": "[to be translated]:Failed to create message",
|
||||
"create_message": "Échec de la création du message",
|
||||
"validation": "Échec de la vérification"
|
||||
},
|
||||
"expand": "Développer",
|
||||
@ -962,7 +970,7 @@
|
||||
"modelType": "Type de modèle",
|
||||
"name": "Nom d'erreur",
|
||||
"no_api_key": "La clé API n'est pas configurée",
|
||||
"no_response": "[to be translated]:No response",
|
||||
"no_response": "Pas de réponse",
|
||||
"originalError": "Erreur d'origine",
|
||||
"originalMessage": "message original",
|
||||
"parameter": "paramètre",
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"agent": {
|
||||
"add": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to add agent",
|
||||
"failed": "エージェントの追加に失敗しました",
|
||||
"invalid_agent": "無効なエージェント"
|
||||
},
|
||||
"title": "エージェントを追加",
|
||||
@ -14,7 +14,7 @@
|
||||
"delete": {
|
||||
"content": "このエージェントを削除すると、このエージェントのすべてのセッションが強制的に終了し、削除されます。本当によろしいですか?",
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to delete the agent"
|
||||
"failed": "エージェントの削除に失敗しました"
|
||||
},
|
||||
"title": "エージェントを削除"
|
||||
},
|
||||
@ -23,39 +23,39 @@
|
||||
},
|
||||
"session": {
|
||||
"add": {
|
||||
"title": "[to be translated]:Add a session"
|
||||
"title": "セッションを追加"
|
||||
},
|
||||
"create": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to add a session"
|
||||
"failed": "セッションの追加に失敗しました"
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"content": "[to be translated]:Are you sure to delete this session?",
|
||||
"content": "このセッションを削除してもよろしいですか?",
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to delete the session"
|
||||
"failed": "セッションの削除に失敗しました"
|
||||
},
|
||||
"title": "[to be translated]:Delete session"
|
||||
"title": "セッションを削除"
|
||||
},
|
||||
"edit": {
|
||||
"title": "[to be translated]:Edit session"
|
||||
"title": "編集セッション"
|
||||
},
|
||||
"get": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to get the session"
|
||||
"failed": "セッションの取得に失敗しました"
|
||||
}
|
||||
},
|
||||
"label_one": "[to be translated]:Session",
|
||||
"label_other": "[to be translated]:Sessions",
|
||||
"label_one": "セッション",
|
||||
"label_other": "セッション",
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to update the session"
|
||||
"failed": "セッションの更新に失敗しました"
|
||||
}
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to update the agent"
|
||||
"failed": "エージェントの更新に失敗しました"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -821,7 +821,7 @@
|
||||
"default": "デフォルト",
|
||||
"delete": "削除",
|
||||
"delete_confirm": "削除してもよろしいですか?",
|
||||
"delete_failed": "[to be translated]:Failed to delete",
|
||||
"delete_failed": "削除に失敗しました",
|
||||
"delete_success": "削除に成功しました",
|
||||
"description": "説明",
|
||||
"detail": "詳細",
|
||||
@ -833,7 +833,7 @@
|
||||
"enabled": "有効",
|
||||
"error": "エラー",
|
||||
"errors": {
|
||||
"create_message": "[to be translated]:Failed to create message",
|
||||
"create_message": "メッセージの作成に失敗しました",
|
||||
"validation": "検証に失敗しました"
|
||||
},
|
||||
"expand": "展開",
|
||||
@ -962,7 +962,7 @@
|
||||
"modelType": "モデルの種類",
|
||||
"name": "エラー名",
|
||||
"no_api_key": "APIキーが設定されていません",
|
||||
"no_response": "[to be translated]:No response",
|
||||
"no_response": "応答なし",
|
||||
"originalError": "元のエラー",
|
||||
"originalMessage": "元のメッセージ",
|
||||
"parameter": "パラメータ",
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"agent": {
|
||||
"add": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to add agent",
|
||||
"failed": "Falha ao adicionar agente",
|
||||
"invalid_agent": "Agent inválido"
|
||||
},
|
||||
"title": "Adicionar Agente",
|
||||
@ -14,7 +14,7 @@
|
||||
"delete": {
|
||||
"content": "Excluir este Agente forçará a terminação e exclusão de todas as sessões sob ele. Tem certeza?",
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to delete the agent"
|
||||
"failed": "Falha ao excluir o agente"
|
||||
},
|
||||
"title": "删除代理"
|
||||
},
|
||||
@ -23,39 +23,47 @@
|
||||
},
|
||||
"session": {
|
||||
"add": {
|
||||
"title": "[to be translated]:Add a session"
|
||||
"title": "Adicionar uma sessão"
|
||||
},
|
||||
"create": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to add a session"
|
||||
"failed": "Falha ao adicionar uma sessão"
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"content": "[to be translated]:Are you sure to delete this session?",
|
||||
"content": "Tem certeza de que deseja excluir esta sessão?",
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to delete the session"
|
||||
"failed": "Falha ao excluir a sessão"
|
||||
},
|
||||
"title": "[to be translated]:Delete session"
|
||||
"title": "Excluir sessão"
|
||||
},
|
||||
"edit": {
|
||||
"title": "[to be translated]:Edit session"
|
||||
"title": "Sessão de edição"
|
||||
},
|
||||
"get": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to get the session"
|
||||
"failed": "Falha ao obter a sessão"
|
||||
}
|
||||
},
|
||||
"label_one": "[to be translated]:Session",
|
||||
"label_other": "[to be translated]:Sessions",
|
||||
"label_one": "Sessão",
|
||||
"label_other": "Sessões",
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to update the session"
|
||||
"failed": "Falha ao atualizar a sessão"
|
||||
}
|
||||
},
|
||||
"accessible_paths": {
|
||||
"label": "Diretórios acessíveis",
|
||||
"add": "Adicionar diretório",
|
||||
"empty": "Selecione pelo menos um diretório ao qual o agente possa acessar.",
|
||||
"required": "Por favor, selecione pelo menos um diretório acessível.",
|
||||
"duplicate": "Este diretório já está incluído.",
|
||||
"select_failed": "Falha ao selecionar o diretório."
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to update the agent"
|
||||
"failed": "Falha ao atualizar o agente"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -821,7 +829,7 @@
|
||||
"default": "Padrão",
|
||||
"delete": "Excluir",
|
||||
"delete_confirm": "Tem certeza de que deseja excluir?",
|
||||
"delete_failed": "[to be translated]:Failed to delete",
|
||||
"delete_failed": "Falha ao excluir",
|
||||
"delete_success": "Excluído com sucesso",
|
||||
"description": "Descrição",
|
||||
"detail": "detalhes",
|
||||
@ -833,7 +841,7 @@
|
||||
"enabled": "Ativado",
|
||||
"error": "错误",
|
||||
"errors": {
|
||||
"create_message": "[to be translated]:Failed to create message",
|
||||
"create_message": "Falha ao criar mensagem",
|
||||
"validation": "Falha na verificação"
|
||||
},
|
||||
"expand": "Expandir",
|
||||
@ -962,7 +970,7 @@
|
||||
"modelType": "Tipo de modelo",
|
||||
"name": "Nome do erro",
|
||||
"no_api_key": "A chave da API não foi configurada",
|
||||
"no_response": "[to be translated]:No response",
|
||||
"no_response": "Sem resposta",
|
||||
"originalError": "Erro original",
|
||||
"originalMessage": "Mensagem original",
|
||||
"parameter": "parâmetro",
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"agent": {
|
||||
"add": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to add agent",
|
||||
"failed": "Не удалось добавить агента",
|
||||
"invalid_agent": "Недействительный агент"
|
||||
},
|
||||
"title": "Добавить агента",
|
||||
@ -14,7 +14,7 @@
|
||||
"delete": {
|
||||
"content": "Удаление этого агента приведёт к принудительному завершению и удалению всех сессий, связанных с ним. Вы уверены?",
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to delete the agent"
|
||||
"failed": "Не удалось удалить агента"
|
||||
},
|
||||
"title": "Удалить агента"
|
||||
},
|
||||
@ -23,39 +23,47 @@
|
||||
},
|
||||
"session": {
|
||||
"add": {
|
||||
"title": "[to be translated]:Add a session"
|
||||
"title": "Добавить сеанс"
|
||||
},
|
||||
"create": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to add a session"
|
||||
"failed": "Не удалось добавить сеанс"
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"content": "[to be translated]:Are you sure to delete this session?",
|
||||
"content": "Вы уверены, что хотите удалить этот сеанс?",
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to delete the session"
|
||||
"failed": "Не удалось удалить сеанс"
|
||||
},
|
||||
"title": "[to be translated]:Delete session"
|
||||
"title": "Удалить сеанс"
|
||||
},
|
||||
"edit": {
|
||||
"title": "[to be translated]:Edit session"
|
||||
"title": "Сессия редактирования"
|
||||
},
|
||||
"get": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to get the session"
|
||||
"failed": "Не удалось получить сеанс"
|
||||
}
|
||||
},
|
||||
"label_one": "[to be translated]:Session",
|
||||
"label_other": "[to be translated]:Sessions",
|
||||
"label_one": "Сессия",
|
||||
"label_other": "Сессии",
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to update the session"
|
||||
"failed": "Не удалось обновить сеанс"
|
||||
}
|
||||
},
|
||||
"accessible_paths": {
|
||||
"label": "Доступные директории",
|
||||
"add": "Добавить каталог",
|
||||
"empty": "Выберите хотя бы один каталог, к которому агент имеет доступ.",
|
||||
"required": "Пожалуйста, выберите хотя бы один доступный каталог.",
|
||||
"duplicate": "Этот каталог уже включён.",
|
||||
"select_failed": "Не удалось выбрать каталог."
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"error": {
|
||||
"failed": "[to be translated]:Failed to update the agent"
|
||||
"failed": "Не удалось обновить агента"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -821,7 +829,7 @@
|
||||
"default": "По умолчанию",
|
||||
"delete": "Удалить",
|
||||
"delete_confirm": "Вы уверены, что хотите удалить?",
|
||||
"delete_failed": "[to be translated]:Failed to delete",
|
||||
"delete_failed": "Не удалось удалить",
|
||||
"delete_success": "Удаление выполнено успешно",
|
||||
"description": "Описание",
|
||||
"detail": "Подробности",
|
||||
@ -833,7 +841,7 @@
|
||||
"enabled": "Включено",
|
||||
"error": "ошибка",
|
||||
"errors": {
|
||||
"create_message": "[to be translated]:Failed to create message",
|
||||
"create_message": "Не удалось создать сообщение",
|
||||
"validation": "Ошибка проверки"
|
||||
},
|
||||
"expand": "Развернуть",
|
||||
@ -962,7 +970,7 @@
|
||||
"modelType": "Тип модели",
|
||||
"name": "Название ошибки",
|
||||
"no_api_key": "Ключ API не настроен",
|
||||
"no_response": "[to be translated]:No response",
|
||||
"no_response": "Нет ответа",
|
||||
"originalError": "Исходная ошибка",
|
||||
"originalMessage": "исходное сообщение",
|
||||
"parameter": "параметр",
|
||||
|
||||
@ -17,7 +17,7 @@ import { classNames } from '@renderer/utils'
|
||||
import { Flex } from 'antd'
|
||||
import { debounce } from 'lodash'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import React, { FC, useState } from 'react'
|
||||
import React, { FC, useMemo,useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@ -141,27 +141,27 @@ const Chat: FC<Props> = (props) => {
|
||||
? 'calc(100vh - var(--navbar-height) - var(--navbar-height) - 12px)'
|
||||
: 'calc(100vh - var(--navbar-height))'
|
||||
|
||||
const SessionMessages = () => {
|
||||
const SessionMessages = useMemo(() => {
|
||||
if (activeAgentId === null) {
|
||||
return <div> Active Agent ID is invalid.</div>
|
||||
return () => <div> Active Agent ID is invalid.</div>
|
||||
}
|
||||
const sessionId = activeSessionId[activeAgentId]
|
||||
if (!sessionId) {
|
||||
return <div> Active Session ID is invalid.</div>
|
||||
return () => <div> Active Session ID is invalid.</div>
|
||||
}
|
||||
return <AgentSessionMessages agentId={activeAgentId} sessionId={sessionId} />
|
||||
}
|
||||
return () => <AgentSessionMessages agentId={activeAgentId} sessionId={sessionId} />
|
||||
}, [activeAgentId, activeSessionId])
|
||||
|
||||
const SessionInputBar = () => {
|
||||
const SessionInputBar = useMemo(() => {
|
||||
if (activeAgentId === null) {
|
||||
return <div> Active Agent ID is invalid.</div>
|
||||
return () => <div> Active Agent ID is invalid.</div>
|
||||
}
|
||||
const sessionId = activeSessionId[activeAgentId]
|
||||
if (!sessionId) {
|
||||
return <div> Active Session ID is invalid.</div>
|
||||
return () => <div> Active Session ID is invalid.</div>
|
||||
}
|
||||
return <AgentSessionInputbar agentId={activeAgentId} sessionId={sessionId} />
|
||||
}
|
||||
return () => <AgentSessionInputbar agentId={activeAgentId} sessionId={sessionId} />
|
||||
}, [activeAgentId, activeSessionId])
|
||||
|
||||
return (
|
||||
<Container id="chat" className={classNames([messageStyle, { 'multi-select-mode': isMultiSelectMode }])}>
|
||||
|
||||
@ -1,16 +1,24 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { QuickPanelView } from '@renderer/components/QuickPanel'
|
||||
import { useSession } from '@renderer/hooks/agents/useSession'
|
||||
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 { sendMessage as dispatchSendMessage } from '@renderer/store/thunk/messageThunk'
|
||||
import type { Assistant, Message, MessageBlock, Model, Topic } from '@renderer/types'
|
||||
import { MessageBlockStatus } from '@renderer/types/newMessage'
|
||||
import { classNames } from '@renderer/utils'
|
||||
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 { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
import NarrowLayout from '../Messages/NarrowLayout'
|
||||
import SendMessageButton from './SendMessageButton'
|
||||
@ -27,7 +35,7 @@ const _text = ''
|
||||
const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
||||
const [text, setText] = useState(_text)
|
||||
const [inputFocus, setInputFocus] = useState(false)
|
||||
const { createSessionMessage } = useSession(agentId, sessionId)
|
||||
const { session } = useSession(agentId, sessionId)
|
||||
|
||||
const { sendMessageShortcut, fontSize, enableSpellCheck } = useSettings()
|
||||
const textareaRef = useRef<TextAreaRef>(null)
|
||||
@ -36,6 +44,8 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
||||
const containerRef = useRef(null)
|
||||
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
const dispatch = useAppDispatch()
|
||||
const sessionTopicId = buildAgentSessionTopicId(sessionId)
|
||||
|
||||
const focusTextarea = useCallback(() => {
|
||||
textareaRef.current?.focus()
|
||||
@ -93,14 +103,71 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
||||
logger.info('Starting to send message')
|
||||
|
||||
try {
|
||||
createSessionMessage(text)
|
||||
// Clear input
|
||||
const userMessageId = uuid()
|
||||
const mainBlock = createMainTextBlock(userMessageId, text, {
|
||||
status: MessageBlockStatus.SUCCESS
|
||||
})
|
||||
const userMessageBlocks: MessageBlock[] = [mainBlock]
|
||||
|
||||
// Extract the actual model ID from session.model (format: "sessionId:modelId")
|
||||
const actualModelId = session?.model ? session.model.split(':').pop() : undefined
|
||||
|
||||
// Try to find the actual model from providers
|
||||
const actualModel = actualModelId ? getModel(actualModelId) : undefined
|
||||
|
||||
const model: Model | undefined = session?.model
|
||||
? {
|
||||
id: session.model,
|
||||
name: actualModel?.name || actualModelId || session.model, // Use actual model name if found
|
||||
provider: actualModel?.provider || 'agent-session',
|
||||
group: actualModel?.group || 'agent-session'
|
||||
}
|
||||
: undefined
|
||||
|
||||
const userMessage: Message = createMessage('user', sessionTopicId, agentId, {
|
||||
id: userMessageId,
|
||||
blocks: userMessageBlocks.map((block) => block.id),
|
||||
model,
|
||||
modelId: model?.id
|
||||
})
|
||||
|
||||
const assistantStub: Assistant = {
|
||||
id: session?.agent_id ?? agentId,
|
||||
name: session?.name ?? 'Agent Session',
|
||||
prompt: session?.instructions ?? '',
|
||||
topics: [] as Topic[],
|
||||
type: 'agent-session',
|
||||
model,
|
||||
defaultModel: model,
|
||||
tags: [],
|
||||
enableWebSearch: false
|
||||
}
|
||||
|
||||
dispatch(
|
||||
dispatchSendMessage(userMessage, userMessageBlocks, assistantStub, sessionTopicId, {
|
||||
agentId,
|
||||
sessionId
|
||||
})
|
||||
)
|
||||
|
||||
setText('')
|
||||
setTimeoutTimer('sendMessage_1', () => setText(''), 500)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to send message:', error as Error)
|
||||
}
|
||||
}, [createSessionMessage, inputEmpty, setTimeoutTimer, text])
|
||||
}, [
|
||||
agentId,
|
||||
dispatch,
|
||||
inputEmpty,
|
||||
session?.agent_id,
|
||||
session?.instructions,
|
||||
session?.model,
|
||||
session?.name,
|
||||
sessionId,
|
||||
sessionTopicId,
|
||||
setTimeoutTimer,
|
||||
text
|
||||
])
|
||||
|
||||
const onChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newText = e.target.value
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
import { loggerService } from '@logger'
|
||||
import ContextMenu from '@renderer/components/ContextMenu'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { useSession } from '@renderer/hooks/agents/useSession'
|
||||
import { ModelMessage } from 'ai'
|
||||
import { memo } from 'react'
|
||||
import { getGroupedMessages } from '@renderer/services/MessagesService'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { selectMessagesForTopic } from '@renderer/store/newMessage'
|
||||
import { Topic } from '@renderer/types'
|
||||
import { buildAgentSessionTopicId } from '@renderer/utils/agentSession'
|
||||
import { memo,useMemo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import MessageGroup from './MessageGroup'
|
||||
import NarrowLayout from './NarrowLayout'
|
||||
import { MessagesContainer, ScrollContainer } from './shared'
|
||||
|
||||
const logger = loggerService.withContext('AgentSessionMessages')
|
||||
|
||||
@ -16,31 +21,54 @@ type Props = {
|
||||
}
|
||||
|
||||
const AgentSessionMessages: React.FC<Props> = ({ agentId, sessionId }) => {
|
||||
const { messages } = useSession(agentId, sessionId)
|
||||
const { session } = useSession(agentId, sessionId)
|
||||
const sessionTopicId = useMemo(() => buildAgentSessionTopicId(sessionId), [sessionId])
|
||||
const messages = useAppSelector((state) => selectMessagesForTopic(state, sessionTopicId))
|
||||
|
||||
const getTextFromContent = (content: string | ModelMessage): string => {
|
||||
logger.debug('content', { content })
|
||||
if (typeof content === 'string') {
|
||||
return content
|
||||
} else if (typeof content.content === 'string') {
|
||||
return content.content
|
||||
} else {
|
||||
return content.content
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('\n')
|
||||
}
|
||||
}
|
||||
const displayMessages = useMemo(() => {
|
||||
if (!messages || messages.length === 0) return []
|
||||
return [...messages].reverse()
|
||||
}, [messages])
|
||||
|
||||
const groupedMessages = useMemo(() => {
|
||||
if (!displayMessages || displayMessages.length === 0) return []
|
||||
return Object.entries(getGroupedMessages(displayMessages))
|
||||
}, [displayMessages])
|
||||
|
||||
const sessionAssistantId = session?.agent_id ?? agentId
|
||||
const sessionName = session?.name ?? sessionId
|
||||
const sessionCreatedAt = session?.created_at ?? session?.updated_at ?? FALLBACK_TIMESTAMP
|
||||
const sessionUpdatedAt = session?.updated_at ?? session?.created_at ?? FALLBACK_TIMESTAMP
|
||||
|
||||
const derivedTopic = useMemo<Topic>(
|
||||
() => ({
|
||||
id: sessionTopicId,
|
||||
assistantId: sessionAssistantId,
|
||||
name: sessionName,
|
||||
createdAt: sessionCreatedAt,
|
||||
updatedAt: sessionUpdatedAt,
|
||||
messages: []
|
||||
}),
|
||||
[sessionTopicId, sessionAssistantId, sessionName, sessionCreatedAt, sessionUpdatedAt]
|
||||
)
|
||||
|
||||
logger.silly('Rendering agent session messages', {
|
||||
sessionId,
|
||||
messageCount: messages.length
|
||||
})
|
||||
|
||||
return (
|
||||
<MessagesContainer id="messages" className="messages-container">
|
||||
<NarrowLayout style={{ display: 'flex', flexDirection: 'column-reverse' }}>
|
||||
<ContextMenu>
|
||||
<ScrollContainer>
|
||||
{messages.toReversed().map((message) => {
|
||||
const content = getTextFromContent(message.content)
|
||||
return <div key={message.id}>{content}</div>
|
||||
})}
|
||||
{groupedMessages.length > 0 ? (
|
||||
groupedMessages.map(([key, groupMessages]) => (
|
||||
<MessageGroup key={key} messages={groupMessages} topic={derivedTopic} />
|
||||
))
|
||||
) : (
|
||||
<EmptyState>{session ? 'No messages yet.' : 'Loading session...'}</EmptyState>
|
||||
)}
|
||||
</ScrollContainer>
|
||||
</ContextMenu>
|
||||
</NarrowLayout>
|
||||
@ -48,25 +76,13 @@ const AgentSessionMessages: React.FC<Props> = ({ agentId, sessionId }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const ScrollContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
padding: 10px 10px 20px;
|
||||
.multi-select-mode & {
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
const EmptyState = styled.div`
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
`
|
||||
|
||||
interface ContainerProps {
|
||||
$right?: boolean
|
||||
}
|
||||
|
||||
const MessagesContainer = styled(Scrollbar)<ContainerProps>`
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
overflow-x: hidden;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
`
|
||||
const FALLBACK_TIMESTAMP = '1970-01-01T00:00:00.000Z'
|
||||
|
||||
export default memo(AgentSessionMessages)
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { loggerService } from '@logger'
|
||||
import ContextMenu from '@renderer/components/ContextMenu'
|
||||
import { LoadingIcon } from '@renderer/components/Icons'
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import { LOAD_MORE_COUNT } from '@renderer/config/constant'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useChatContext } from '@renderer/hooks/useChatContext'
|
||||
@ -41,6 +40,7 @@ import MessageAnchorLine from './MessageAnchorLine'
|
||||
import MessageGroup from './MessageGroup'
|
||||
import NarrowLayout from './NarrowLayout'
|
||||
import Prompt from './Prompt'
|
||||
import { MessagesContainer, ScrollContainer } from './shared'
|
||||
|
||||
interface MessagesProps {
|
||||
assistant: Assistant
|
||||
@ -392,25 +392,4 @@ const LoaderContainer = styled.div`
|
||||
pointer-events: none;
|
||||
`
|
||||
|
||||
const ScrollContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
padding: 10px 10px 20px;
|
||||
.multi-select-mode & {
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
`
|
||||
|
||||
interface ContainerProps {
|
||||
$right?: boolean
|
||||
}
|
||||
|
||||
const MessagesContainer = styled(Scrollbar)<ContainerProps>`
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
overflow-x: hidden;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
`
|
||||
|
||||
export default Messages
|
||||
|
||||
23
src/renderer/src/pages/home/Messages/shared.tsx
Normal file
23
src/renderer/src/pages/home/Messages/shared.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import Scrollbar from '@renderer/components/Scrollbar'
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const ScrollContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
padding: 10px 10px 20px;
|
||||
.multi-select-mode & {
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
`
|
||||
|
||||
interface ContainerProps {
|
||||
$right?: boolean
|
||||
}
|
||||
|
||||
export const MessagesContainer = styled(Scrollbar)<ContainerProps>`
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
overflow-x: hidden;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
`
|
||||
@ -5,7 +5,7 @@ import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setActiveAgentId as setActiveAgentIdAction } from '@renderer/store/runtime'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { FC, useCallback } from 'react'
|
||||
import { FC, useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import AgentItem from './components/AgentItem'
|
||||
@ -27,6 +27,12 @@ export const AgentsTab: FC<AssistantsTabProps> = () => {
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && agents.length > 0 && !activeAgentId) {
|
||||
setActiveAgentId(agents[0].id)
|
||||
}
|
||||
}, [isLoading, agents, activeAgentId, setActiveAgentId])
|
||||
|
||||
return (
|
||||
<div className="agents-tab h-full w-full p-2">
|
||||
{isLoading && <Spinner />}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { Spinner } from '@heroui/react'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { FC, memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import Sessions from './components/Sessions'
|
||||
|
||||
@ -8,15 +11,38 @@ interface SessionsTabProps {}
|
||||
const SessionsTab: FC<SessionsTabProps> = () => {
|
||||
const { chat } = useRuntime()
|
||||
const { activeAgentId } = chat
|
||||
|
||||
if (!activeAgentId) {
|
||||
return <div> No active agent.</div>
|
||||
}
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sessions agentId={activeAgentId} />
|
||||
</>
|
||||
<AnimatePresence mode="wait">
|
||||
{!activeAgentId ? (
|
||||
<motion.div
|
||||
key="loading"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex h-full flex-col items-center justify-center gap-3">
|
||||
<Spinner size="lg" color="primary" />
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.3 }}
|
||||
className="text-sm text-foreground-500">
|
||||
{t('common.loading')}...
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key={activeAgentId}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}>
|
||||
<Sessions agentId={activeAgentId} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { Button, Spinner } from '@heroui/react'
|
||||
import { SessionModal } from '@renderer/components/Popups/agent/SessionModal'
|
||||
import { useSessions } from '@renderer/hooks/agents/useSessions'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setActiveSessionIdAction, setActiveTopicOrSessionAction } from '@renderer/store/runtime'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { memo, useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import SessionItem from './SessionItem'
|
||||
@ -18,6 +20,8 @@ interface SessionsProps {
|
||||
const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
|
||||
const { t } = useTranslation()
|
||||
const { sessions, isLoading, deleteSession } = useSessions(agentId)
|
||||
const { chat } = useRuntime()
|
||||
const { activeSessionId } = chat
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const setActiveSessionId = useCallback(
|
||||
@ -28,36 +32,71 @@ const Sessions: React.FC<SessionsProps> = ({ agentId }) => {
|
||||
[dispatch]
|
||||
)
|
||||
|
||||
if (isLoading) return <Spinner />
|
||||
const currentActiveSessionId = activeSessionId[agentId]
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && sessions.length > 0 && !currentActiveSessionId) {
|
||||
setActiveSessionId(agentId, sessions[0].id)
|
||||
}
|
||||
}, [isLoading, sessions, currentActiveSessionId, agentId, setActiveSessionId])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex h-full items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
// if (error) return
|
||||
|
||||
return (
|
||||
<div className="agents-tab h-full w-full p-2">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="agents-tab h-full w-full p-2">
|
||||
{/* TODO: Add session button */}
|
||||
<SessionModal
|
||||
agentId={agentId}
|
||||
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>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{sessions.map((session) => (
|
||||
<SessionItem
|
||||
key={session.id}
|
||||
session={session}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: 0.1 }}>
|
||||
<SessionModal
|
||||
agentId={agentId}
|
||||
onDelete={() => deleteSession(session.id)}
|
||||
onPress={() => setActiveSessionId(agentId, session.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>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
<AnimatePresence>
|
||||
{sessions.map((session, index) => (
|
||||
<motion.div
|
||||
key={session.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20, transition: { duration: 0.2 } }}
|
||||
transition={{ duration: 0.3, delay: index * 0.05 }}>
|
||||
<SessionItem
|
||||
session={session}
|
||||
agentId={agentId}
|
||||
onDelete={() => deleteSession(session.id)}
|
||||
onPress={() => setActiveSessionId(agentId, session.id)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -19,11 +19,10 @@ export const hasModel = (m?: Model) => {
|
||||
}
|
||||
|
||||
export function getModelName(model?: Model) {
|
||||
const provider = store.getState().llm.providers.find((p) => p.id === model?.provider)
|
||||
const modelName = model?.name || model?.id || ''
|
||||
|
||||
const provider = store.getState().llm.providers.find((p) => p.id === model?.provider)
|
||||
if (provider) {
|
||||
const providerName = getProviderName(model)
|
||||
const providerName = getProviderName(model as Model)
|
||||
return `${modelName} | ${providerName}`
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { AiSdkToChunkAdapter } from '@renderer/aiCore/chunk/AiSdkToChunkAdapter'
|
||||
import db from '@renderer/databases'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { BlockManager } from '@renderer/services/messageStreaming/BlockManager'
|
||||
@ -8,18 +9,23 @@ import { endSpan } from '@renderer/services/SpanManagerService'
|
||||
import { createStreamProcessor, type StreamProcessorCallbacks } from '@renderer/services/StreamProcessingService'
|
||||
import store from '@renderer/store'
|
||||
import { updateTopicUpdatedAt } from '@renderer/store/assistants'
|
||||
import { type Assistant, type FileMetadata, type Model, type Topic } from '@renderer/types'
|
||||
import { type ApiServerConfig, type Assistant, type FileMetadata, type Model, type Topic } from '@renderer/types'
|
||||
import type { AgentPersistedMessage } from '@renderer/types/agent'
|
||||
import type { FileMessageBlock, ImageMessageBlock, Message, MessageBlock } from '@renderer/types/newMessage'
|
||||
import { AssistantMessageStatus, MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { uuid } from '@renderer/utils'
|
||||
import { addAbortController } from '@renderer/utils/abortController'
|
||||
import { isAgentSessionTopicId } from '@renderer/utils/agentSession'
|
||||
import {
|
||||
createAssistantMessage,
|
||||
createTranslationBlock,
|
||||
resetAssistantMessage
|
||||
} from '@renderer/utils/messageUtils/create'
|
||||
import { getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||
import { getTopicQueue, waitForTopicQueue } from '@renderer/utils/queue'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { defaultAppHeaders } from '@shared/utils'
|
||||
import type { TextStreamPart } from 'ai'
|
||||
import { t } from 'i18next'
|
||||
import { isEmpty, throttle } from 'lodash'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
@ -35,9 +41,158 @@ const finishTopicLoading = async (topicId: string) => {
|
||||
store.dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false }))
|
||||
store.dispatch(newMessagesActions.setTopicFulfilled({ topicId, fulfilled: true }))
|
||||
}
|
||||
|
||||
type AgentSessionContext = {
|
||||
agentId: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
const buildAgentBaseURL = (apiServer: ApiServerConfig) => {
|
||||
const hasProtocol = apiServer.host.startsWith('http://') || apiServer.host.startsWith('https://')
|
||||
const baseHost = hasProtocol ? apiServer.host : `http://${apiServer.host}`
|
||||
const portSegment = apiServer.port ? `:${apiServer.port}` : ''
|
||||
return `${baseHost}${portSegment}`
|
||||
}
|
||||
|
||||
const createSSEReadableStream = (
|
||||
source: ReadableStream<Uint8Array>,
|
||||
signal: AbortSignal
|
||||
): ReadableStream<TextStreamPart<Record<string, any>>> => {
|
||||
return new ReadableStream<TextStreamPart<Record<string, any>>>({
|
||||
start(controller) {
|
||||
const reader = source.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
const cancelReader = (reason?: any) => reader.cancel(reason).catch(() => {})
|
||||
|
||||
const abortHandler = () => {
|
||||
cancelReader(signal.reason ?? 'aborted')
|
||||
controller.error(new DOMException('Aborted', 'AbortError'))
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
abortHandler()
|
||||
return
|
||||
}
|
||||
|
||||
signal.addEventListener('abort', abortHandler, { once: true })
|
||||
|
||||
const emitEvent = (eventString: string): boolean => {
|
||||
const lines = eventString.split(/\r?\n/)
|
||||
let dataPayload = ''
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
dataPayload += line.slice(5).trimStart()
|
||||
}
|
||||
}
|
||||
|
||||
if (!dataPayload) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (dataPayload === '[DONE]') {
|
||||
signal.removeEventListener('abort', abortHandler)
|
||||
cancelReader()
|
||||
controller.close()
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(dataPayload) as TextStreamPart<Record<string, any>>
|
||||
controller.enqueue(parsed)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse agent SSE chunk', { dataPayload })
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const pump = async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
let separatorIndex = buffer.indexOf('\n\n')
|
||||
while (separatorIndex !== -1) {
|
||||
const rawEvent = buffer.slice(0, separatorIndex).trim()
|
||||
buffer = buffer.slice(separatorIndex + 2)
|
||||
if (rawEvent) {
|
||||
const shouldStop = emitEvent(rawEvent)
|
||||
if (shouldStop) {
|
||||
return
|
||||
}
|
||||
}
|
||||
separatorIndex = buffer.indexOf('\n\n')
|
||||
}
|
||||
}
|
||||
|
||||
buffer += decoder.decode()
|
||||
if (buffer.trim()) {
|
||||
emitEvent(buffer.trim())
|
||||
}
|
||||
signal.removeEventListener('abort', abortHandler)
|
||||
controller.close()
|
||||
} catch (error) {
|
||||
signal.removeEventListener('abort', abortHandler)
|
||||
controller.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
pump().catch((error) => {
|
||||
signal.removeEventListener('abort', abortHandler)
|
||||
controller.error(error)
|
||||
})
|
||||
},
|
||||
cancel(reason) {
|
||||
return source.cancel(reason).catch(() => {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const createAgentMessageStream = async (
|
||||
apiServer: ApiServerConfig,
|
||||
agentSession: AgentSessionContext,
|
||||
content: string,
|
||||
signal: AbortSignal
|
||||
): Promise<ReadableStream<TextStreamPart<Record<string, any>>>> => {
|
||||
if (!apiServer.enabled) {
|
||||
throw new Error('Agent API server is disabled')
|
||||
}
|
||||
|
||||
const baseURL = buildAgentBaseURL(apiServer)
|
||||
const url = `${baseURL}/v1/agents/${agentSession.agentId}/sessions/${agentSession.sessionId}/messages`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiServer.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'text/event-stream',
|
||||
'Cache-Control': 'no-cache'
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => '')
|
||||
throw new Error(errorText || `Failed to stream agent message: ${response.status}`)
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('Agent message stream has no body')
|
||||
}
|
||||
|
||||
return createSSEReadableStream(response.body, signal)
|
||||
}
|
||||
// TODO: 后续可以将db操作移到Listener Middleware中
|
||||
export const saveMessageAndBlocksToDB = async (message: Message, blocks: MessageBlock[], messageIndex: number = -1) => {
|
||||
try {
|
||||
if (isAgentSessionTopicId(message.topicId)) {
|
||||
return
|
||||
}
|
||||
if (blocks.length > 0) {
|
||||
await db.message_blocks.bulkPut(blocks)
|
||||
}
|
||||
@ -70,6 +225,9 @@ const updateExistingMessageAndBlocksInDB = async (
|
||||
updatedBlocks: MessageBlock[]
|
||||
) => {
|
||||
try {
|
||||
if (isAgentSessionTopicId(updatedMessage.topicId)) {
|
||||
return
|
||||
}
|
||||
await db.transaction('rw', db.topics, db.message_blocks, async () => {
|
||||
// Always update blocks if provided
|
||||
if (updatedBlocks.length > 0) {
|
||||
@ -244,6 +402,157 @@ const saveUpdatedBlockToDB = async (
|
||||
}
|
||||
}
|
||||
|
||||
interface AgentStreamParams {
|
||||
topicId: string
|
||||
assistant: Assistant
|
||||
assistantMessage: Message
|
||||
agentSession: AgentSessionContext
|
||||
userMessageId: string
|
||||
}
|
||||
|
||||
const fetchAndProcessAgentResponseImpl = async (
|
||||
dispatch: AppDispatch,
|
||||
getState: () => RootState,
|
||||
{ topicId, assistant, assistantMessage, agentSession, userMessageId }: AgentStreamParams
|
||||
) => {
|
||||
let callbacks: StreamProcessorCallbacks = {}
|
||||
try {
|
||||
dispatch(newMessagesActions.setTopicLoading({ topicId, loading: true }))
|
||||
|
||||
const blockManager = new BlockManager({
|
||||
dispatch,
|
||||
getState,
|
||||
saveUpdatedBlockToDB,
|
||||
saveUpdatesToDB,
|
||||
assistantMsgId: assistantMessage.id,
|
||||
topicId,
|
||||
throttledBlockUpdate,
|
||||
cancelThrottledBlockUpdate
|
||||
})
|
||||
|
||||
callbacks = createCallbacks({
|
||||
blockManager,
|
||||
dispatch,
|
||||
getState,
|
||||
topicId,
|
||||
assistantMsgId: assistantMessage.id,
|
||||
saveUpdatesToDB,
|
||||
assistant
|
||||
})
|
||||
|
||||
const streamProcessorCallbacks = createStreamProcessor(callbacks)
|
||||
|
||||
const state = getState()
|
||||
const userMessageEntity = state.messages.entities[userMessageId]
|
||||
const userContent = userMessageEntity ? getMainTextContent(userMessageEntity) : ''
|
||||
|
||||
const abortController = new AbortController()
|
||||
addAbortController(userMessageId, () => abortController.abort())
|
||||
|
||||
const stream = await createAgentMessageStream(
|
||||
state.settings.apiServer,
|
||||
agentSession,
|
||||
userContent,
|
||||
abortController.signal
|
||||
)
|
||||
|
||||
let latestAgentSessionId = ''
|
||||
const adapter = new AiSdkToChunkAdapter(streamProcessorCallbacks, [], false, false, (sessionId) => {
|
||||
latestAgentSessionId = sessionId
|
||||
})
|
||||
|
||||
await adapter.processStream({
|
||||
fullStream: stream,
|
||||
text: Promise.resolve('')
|
||||
})
|
||||
|
||||
await persistAgentExchange({
|
||||
getState,
|
||||
agentSession,
|
||||
userMessageId,
|
||||
assistantMessageId: assistantMessage.id,
|
||||
latestAgentSessionId
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error('Error in fetchAndProcessAgentResponseImpl:', error)
|
||||
try {
|
||||
callbacks.onError?.(error)
|
||||
} catch (callbackError) {
|
||||
logger.error('Error in agent onError callback:', callbackError as Error)
|
||||
} finally {
|
||||
dispatch(newMessagesActions.setTopicLoading({ topicId, loading: false }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface PersistAgentExchangeParams {
|
||||
getState: () => RootState
|
||||
agentSession: AgentSessionContext
|
||||
userMessageId: string
|
||||
assistantMessageId: string
|
||||
latestAgentSessionId: string
|
||||
}
|
||||
|
||||
const persistAgentExchange = async ({
|
||||
getState,
|
||||
agentSession,
|
||||
userMessageId,
|
||||
assistantMessageId,
|
||||
latestAgentSessionId
|
||||
}: PersistAgentExchangeParams) => {
|
||||
if (!window.electron?.ipcRenderer) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const state = getState()
|
||||
const userMessage = state.messages.entities[userMessageId]
|
||||
const assistantMessage = state.messages.entities[assistantMessageId]
|
||||
|
||||
if (!userMessage || !assistantMessage) {
|
||||
logger.warn('persistAgentExchange: missing user or assistant message entity')
|
||||
return
|
||||
}
|
||||
|
||||
const userPersistedPayload = createPersistedMessagePayload(userMessage, state)
|
||||
const assistantPersistedPayload = createPersistedMessagePayload(assistantMessage, state)
|
||||
|
||||
await window.electron.ipcRenderer.invoke(IpcChannel.AgentMessage_PersistExchange, {
|
||||
sessionId: agentSession.sessionId,
|
||||
agentSessionId: latestAgentSessionId || '',
|
||||
user: userPersistedPayload ? { payload: userPersistedPayload } : undefined,
|
||||
assistant: assistantPersistedPayload ? { payload: assistantPersistedPayload } : undefined
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn('Failed to persist agent exchange', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
const createPersistedMessagePayload = (
|
||||
message: Message | undefined,
|
||||
state: RootState
|
||||
): AgentPersistedMessage | undefined => {
|
||||
if (!message) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const clonedMessage = JSON.parse(JSON.stringify(message)) as Message
|
||||
const blockEntities = (message.blocks || [])
|
||||
.map((blockId) => state.messageBlocks.entities[blockId])
|
||||
.filter((block): block is MessageBlock => Boolean(block))
|
||||
.map((block) => JSON.parse(JSON.stringify(block)) as MessageBlock)
|
||||
|
||||
return {
|
||||
message: clonedMessage,
|
||||
blocks: blockEntities
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to build persisted payload for message', error as Error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper Function for Multi-Model Dispatch ---
|
||||
// 多模型创建和发送请求的逻辑,用于用户消息多模型发送和重发
|
||||
const dispatchMultiModelResponses = async (
|
||||
@ -385,7 +694,7 @@ const fetchAndProcessAssistantResponseImpl = async (
|
||||
})
|
||||
// 统一错误处理:确保 loading 状态被正确设置,避免队列任务卡住
|
||||
try {
|
||||
await callbacks.onError?.(error)
|
||||
callbacks.onError?.(error)
|
||||
} catch (callbackError) {
|
||||
logger.error('Error in onError callback:', callbackError as Error)
|
||||
} finally {
|
||||
@ -403,7 +712,13 @@ const fetchAndProcessAssistantResponseImpl = async (
|
||||
* @param topicId 主题ID
|
||||
*/
|
||||
export const sendMessage =
|
||||
(userMessage: Message, userMessageBlocks: MessageBlock[], assistant: Assistant, topicId: Topic['id']) =>
|
||||
(
|
||||
userMessage: Message,
|
||||
userMessageBlocks: MessageBlock[],
|
||||
assistant: Assistant,
|
||||
topicId: Topic['id'],
|
||||
agentSession?: AgentSessionContext
|
||||
) =>
|
||||
async (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
try {
|
||||
if (userMessage.blocks.length === 0) {
|
||||
@ -417,12 +732,9 @@ export const sendMessage =
|
||||
}
|
||||
dispatch(updateTopicUpdatedAt({ topicId }))
|
||||
|
||||
const mentionedModels = userMessage.mentions
|
||||
const queue = getTopicQueue(topicId)
|
||||
|
||||
if (mentionedModels && mentionedModels.length > 0) {
|
||||
await dispatchMultiModelResponses(dispatch, getState, topicId, userMessage, assistant, mentionedModels)
|
||||
} else {
|
||||
if (agentSession) {
|
||||
const assistantMessage = createAssistantMessage(assistant.id, topicId, {
|
||||
askId: userMessage.id,
|
||||
model: assistant.model,
|
||||
@ -432,8 +744,32 @@ export const sendMessage =
|
||||
dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage }))
|
||||
|
||||
queue.add(async () => {
|
||||
await fetchAndProcessAssistantResponseImpl(dispatch, getState, topicId, assistant, assistantMessage)
|
||||
await fetchAndProcessAgentResponseImpl(dispatch, getState, {
|
||||
topicId,
|
||||
assistant,
|
||||
assistantMessage,
|
||||
agentSession,
|
||||
userMessageId: userMessage.id
|
||||
})
|
||||
})
|
||||
} else {
|
||||
const mentionedModels = userMessage.mentions
|
||||
|
||||
if (mentionedModels && mentionedModels.length > 0) {
|
||||
await dispatchMultiModelResponses(dispatch, getState, topicId, userMessage, assistant, mentionedModels)
|
||||
} else {
|
||||
const assistantMessage = createAssistantMessage(assistant.id, topicId, {
|
||||
askId: userMessage.id,
|
||||
model: assistant.model,
|
||||
traceId: userMessage.traceId
|
||||
})
|
||||
await saveMessageAndBlocksToDB(assistantMessage, [])
|
||||
dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage }))
|
||||
|
||||
queue.add(async () => {
|
||||
await fetchAndProcessAssistantResponseImpl(dispatch, getState, topicId, assistant, assistantMessage)
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error in sendMessage thunk:', error as Error)
|
||||
|
||||
@ -4,9 +4,11 @@
|
||||
*
|
||||
* WARNING: Any null value will be converted to undefined from api.
|
||||
*/
|
||||
import { ModelMessage, modelMessageSchema, TextStreamPart } from 'ai'
|
||||
import { TextStreamPart } from 'ai'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { Message, MessageBlock } from './newMessage'
|
||||
|
||||
// ------------------ Core enums and helper types ------------------
|
||||
export const PermissionModeSchema = z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan'])
|
||||
export type PermissionMode = z.infer<typeof PermissionModeSchema>
|
||||
@ -109,8 +111,8 @@ export const AgentSessionMessageEntitySchema = z.object({
|
||||
id: z.number(), // Auto-increment primary key
|
||||
session_id: z.string(), // Reference to session
|
||||
// manual defined. may not synced with ai sdk definition
|
||||
role: SessionMessageRoleSchema, // Enforce roles supported by modelMessageSchema
|
||||
content: modelMessageSchema,
|
||||
role: SessionMessageRoleSchema,
|
||||
content: z.unknown(),
|
||||
agent_session_id: z.string(), // agent session id, use to resume agent session
|
||||
metadata: z.record(z.string(), z.any()).optional(), // Additional metadata (optional)
|
||||
created_at: z.iso.datetime(), // ISO timestamp
|
||||
@ -119,6 +121,35 @@ export const AgentSessionMessageEntitySchema = z.object({
|
||||
|
||||
export type AgentSessionMessageEntity = z.infer<typeof AgentSessionMessageEntitySchema>
|
||||
|
||||
export interface AgentPersistedMessage {
|
||||
message: Message
|
||||
blocks: MessageBlock[]
|
||||
}
|
||||
|
||||
export interface AgentMessageUserPersistPayload {
|
||||
payload: AgentPersistedMessage
|
||||
metadata?: Record<string, unknown>
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface AgentMessageAssistantPersistPayload {
|
||||
payload: AgentPersistedMessage
|
||||
metadata?: Record<string, unknown>
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface AgentMessagePersistExchangePayload {
|
||||
sessionId: string
|
||||
agentSessionId: string
|
||||
user?: AgentMessageUserPersistPayload
|
||||
assistant?: AgentMessageAssistantPersistPayload
|
||||
}
|
||||
|
||||
export interface AgentMessagePersistExchangeResult {
|
||||
userMessage?: AgentSessionMessageEntity
|
||||
assistantMessage?: AgentSessionMessageEntity
|
||||
}
|
||||
|
||||
// ------------------ Session message payload ------------------
|
||||
|
||||
// Not implemented fields:
|
||||
|
||||
@ -17,6 +17,7 @@ export const ApiModelSchema = z.object({
|
||||
name: z.string(),
|
||||
owned_by: z.string(),
|
||||
provider: z.string().optional(),
|
||||
provider_name: z.string().optional(),
|
||||
provider_type: ProviderTypeSchema.optional(),
|
||||
provider_model_id: z.string().optional()
|
||||
})
|
||||
|
||||
9
src/renderer/src/utils/agentSession.ts
Normal file
9
src/renderer/src/utils/agentSession.ts
Normal file
@ -0,0 +1,9 @@
|
||||
const SESSION_TOPIC_PREFIX = 'agent-session:'
|
||||
|
||||
export const buildAgentSessionTopicId = (sessionId: string): string => {
|
||||
return `${SESSION_TOPIC_PREFIX}${sessionId}`
|
||||
}
|
||||
|
||||
export const isAgentSessionTopicId = (topicId: string): boolean => {
|
||||
return topicId.startsWith(SESSION_TOPIC_PREFIX)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user