import { computed, reactive, ref, toRaw } from 'vue'
import {
localStorageStrategyFactory,
useConversation as useConversationKit,
type ChatCompletion,
type ChatMessage,
type ConversationInfo,
type ConversationStorageStrategy,
type UseMessageOptions,
type UseMessagePlugin
} from '@opentiny/tiny-robot-kit'
import type { CompletionChoice } from '@opentiny/tiny-robot-kit'
import { STATUS, type MessageState } from '../../constants/status'
import type { OpenAICompatibleProvider } from '../../services/OpenAICompatibleProvider'
import { extractMessageText } from '../../utils/chat.utils'
import { RobotMessageRole } from '../../types/chat.types'
export interface ConversationAdapterOptions {
provider: Pick<OpenAICompatibleProvider, 'chatStream'>
onStreamData: (data: any, messages: any[]) => void
onFinishRequest: (finishReason: string, messages: any[], contextMessages: any[], messageState: any) => Promise<void>
onMessageProcessed: (finishReason: string, content: any, messages: any[], context: any) => Promise<void>
statusManager: {
isProcessing: () => boolean
setProcessing: () => void
resetProcessing: () => void
}
}
export interface ConversationMetadata {
chatMode?: string
[key: string]: any
}
let currentConversationMetadata: ConversationMetadata = {}
const createResponseProvider = (
provider: Pick<OpenAICompatibleProvider, 'chatStream'>
): UseMessageOptions['responseProvider'] => {
return async function* responseProvider(requestBody, abortSignal) {
const queue: ChatCompletion[] = []
let streamError: unknown
let finished = false
let wakeUp: (() => void) | undefined
const notify = () => {
wakeUp?.()
wakeUp = undefined
}
const handleAbort = () => {
finished = true
notify()
}
abortSignal.addEventListener('abort', handleAbort, { once: true })
const streamTask = provider
.chatStream(
{ messages: requestBody.messages as ChatMessage[], options: { signal: abortSignal } },
{
onData: (data) => {
queue.push(data as ChatCompletion)
notify()
},
onError: (error) => {
streamError = error
finished = true
notify()
},
onDone: () => {
finished = true
notify()
}
}
)
.catch((error) => {
streamError = error
finished = true
notify()
})
try {
while (!finished || queue.length > 0) {
if (queue.length === 0) {
await new Promise<void>((resolve) => {
wakeUp = resolve
})
continue
}
yield queue.shift() as ChatCompletion
}
if (streamError) {
throw streamError
}
} finally {
abortSignal.removeEventListener('abort', handleAbort)
await streamTask
}
}
}
const updateMessageMetadata = (currentMessage: ChatMessage, chunk: ChatCompletion, choice?: CompletionChoice) => {
currentMessage.role =
choice?.delta?.role || choice?.message?.role || currentMessage.role || RobotMessageRole.Assistant
currentMessage.loading = undefined
currentMessage.renderContent ||= []
currentMessage.metadata ||= {}
currentMessage.metadata.chatMode ||= currentConversationMetadata.chatMode
currentMessage.metadata.createdAt ||= chunk.created
currentMessage.metadata.updatedAt = Math.floor(Date.now() / 1000)
currentMessage.metadata.id ||= chunk.id
currentMessage.metadata.model ||= chunk.model
}
* Conversation 适配器
* 基于 tiny-robot-kit v0.4 的 useConversation/useMessage,对外保持旧业务层接口
*/
export function useConversationAdapter(options: ConversationAdapterOptions) {
const { provider, onStreamData, onFinishRequest, statusManager } = options
const storage: ConversationStorageStrategy = localStorageStrategyFactory()
const messageState = reactive<MessageState>({
status: STATUS.FINISHED
})
const emptyMessages = ref<ChatMessage[]>([])
const adapterPlugin: UseMessagePlugin = {
name: 'robot-conversation-adapter',
async onAfterRequest({ messages, lastChoice }) {
if (statusManager.isProcessing()) {
return
}
statusManager.setProcessing()
const finishReason = lastChoice?.finish_reason || 'stop'
const contextMessages = toRaw(messages.slice(0, -1))
await onFinishRequest(finishReason, messages, contextMessages, messageState)
},
onError({ error }) {
messageState.status = STATUS.ERROR
messageState.errorMsg = error
}
}
const {
conversations,
activeConversationId,
activeConversation,
createConversation: createConversationKit,
switchConversation: switchConversationKit,
deleteConversation,
clear,
updateConversationTitle,
saveMessages,
abortActiveRequest
} = useConversationKit({
autoSaveMessages: true,
storage,
useMessageOptions: {
responseProvider: createResponseProvider(provider),
plugins: [adapterPlugin],
onCompletionChunk({ currentMessage, messages, chunk, choice }, runDefault) {
runDefault()
updateMessageMetadata(currentMessage, chunk, choice)
onStreamData({ ...chunk, __contentAlreadyMerged: true }, messages)
}
}
})
const getActiveEngine = () => activeConversation.value?.engine
const saveConversation = (conversation: ConversationInfo) => {
const rawConversation = {
...toRaw(conversation),
metadata: toRaw(conversation.metadata)
}
return storage.saveConversation?.(rawConversation)
}
const saveConversations = () => {
conversations.value.forEach((conversation) => {
saveConversation(conversation)
})
}
const updateMetadata = (conversationId: string, metadata: ConversationMetadata = {}) => {
const conversation = conversations.value.find((item) => item.id === conversationId)
if (!conversation) {
return
}
conversation.metadata = {
...(conversation.metadata || {}),
...metadata
}
if (conversationId === activeConversationId.value) {
currentConversationMetadata = conversation.metadata
}
conversation.updatedAt = Date.now()
saveConversation(conversation)
}
const updateTitle = (conversationId: string, title?: string) => {
updateConversationTitle(conversationId, title)
}
const isConversationEmpty = (conversationId?: string | null) => {
if (!conversationId || conversationId !== activeConversationId.value) {
return false
}
const messages = getActiveEngine()?.messages.value || []
return !messages.some(
(message) =>
message.role !== 'system' &&
(message.content ||
message.tool_calls?.length ||
message.tool_call_id ||
(Array.isArray(message.renderContent) && message.renderContent.length))
)
}
const conversationState = reactive({
get currentId() {
return activeConversationId.value
},
get conversations() {
return conversations.value
}
})
const messageManager = {
messages: computed(() => getActiveEngine()?.messages.value ?? emptyMessages.value),
messageState,
sendMessage: (content: string) => getActiveEngine()?.sendMessage(content) ?? Promise.resolve(),
send: (...msgs: ChatMessage[]) => getActiveEngine()?.send(...msgs) ?? Promise.resolve(),
abortRequest: () => abortActiveRequest()
}
* 创建新会话
* @param title 会话标题
* @param metadata 会话元数据(如 chatMode)
*/
const createConversation = (title: string, metadata?: ConversationMetadata) => {
if (isConversationEmpty(activeConversationId.value)) {
const currentId = activeConversationId.value as string
const conversation = conversations.value.find((item) => item.id === currentId)
if (conversation) {
conversation.title = title
conversation.updatedAt = Date.now()
if (metadata) {
conversation.metadata = {
...(conversation.metadata || {}),
...metadata
}
}
currentConversationMetadata = conversation.metadata || {}
saveConversation(conversation)
return currentId
}
}
const conversation = createConversationKit({ title, metadata })
currentConversationMetadata = conversation.metadata || {}
return conversation.id
}
* 切换会话
* @param conversationId 会话ID
* @param onStart 切换成功后的回调
*/
const switchConversation = async (
conversationId: string,
onStart?: (state: any, messages: any, methods: any) => void
) => {
const conversation = conversations.value.find((item) => item.id === conversationId)
if (!conversation) {
return null
}
const result = await switchConversationKit(conversationId)
if (result && onStart) {
currentConversationMetadata = conversation.metadata || {}
onStart(conversationState, messageManager.messages.value, {
createConversation,
switchConversation,
deleteConversation,
clear,
saveMessages,
saveConversations,
updateTitle,
updateMetadata,
abortActiveRequest
})
}
return result
}
const apis = {
createConversation,
switchConversation,
deleteConversation,
clear,
saveMessages,
saveConversations,
updateTitle,
updateMetadata,
abortActiveRequest
}
* 自动设置会话标题
* @param currentId 当前会话ID
* @param defaultTitle 默认标题
*/
const autoSetTitle = (currentId: string, defaultTitle = '新会话') => {
const currentConversation = conversations.value.find((conversation) => conversation.id === currentId)
if (!currentConversation || currentId !== activeConversationId.value) {
return
}
const currentTitle = currentConversation.title
if (currentTitle === defaultTitle && currentId) {
const messageContent = getActiveEngine()?.messages.value.find((item) => item.role === 'user')?.content
const contentStr = extractMessageText(messageContent) || JSON.stringify(messageContent)
updateTitle(currentId, contentStr.substring(0, 20))
}
}
return {
messageManager,
conversationState,
...apis,
autoSetTitle
}
}