diff --git a/lint-staged.config.js b/lint-staged.config.js index f794152af8..21f321f7ac 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -1,4 +1,4 @@ module.exports = { - './packages/**/**.{js,mjs,jsx,ts,mts,tsx,vue}': 'eslint', + './packages/**/**.{js,mjs,jsx,ts,mts,tsx,vue}': 'eslint --no-warn-ignored', './packages/**/**.{js,mjs,jsx,ts,mts,tsx,vue,html,json,less}': 'prettier --write' } diff --git a/packages/plugins/robot/package.json b/packages/plugins/robot/package.json index 02853706e1..058113b5ac 100644 --- a/packages/plugins/robot/package.json +++ b/packages/plugins/robot/package.json @@ -5,7 +5,9 @@ "access": "public" }, "scripts": { - "build": "vite build" + "build": "vite build", + "test": "vitest run", + "test:watch": "vitest" }, "type": "module", "main": "dist/index.js", @@ -29,9 +31,9 @@ "@opentiny/tiny-engine-common": "workspace:*", "@opentiny/tiny-engine-meta-register": "workspace:*", "@opentiny/tiny-engine-utils": "workspace:*", - "@opentiny/tiny-robot": "0.3.1", - "@opentiny/tiny-robot-kit": "0.3.1", - "@opentiny/tiny-robot-svgs": "0.3.1", + "@opentiny/tiny-robot": "0.4.0", + "@opentiny/tiny-robot-kit": "0.4.0", + "@opentiny/tiny-robot-svgs": "0.4.0", "@opentiny/tiny-schema-renderer": "1.0.0-beta.6", "@vueuse/core": "^9.13.0", "dompurify": "^3.0.1", @@ -45,7 +47,8 @@ "@types/markdown-it": "^14.1.2", "@vitejs/plugin-vue": "^5.1.2", "@vitejs/plugin-vue-jsx": "^4.0.1", - "vite": "^5.4.2" + "vite": "^5.4.2", + "vitest": "^1.6.1" }, "peerDependencies": { "@opentiny/vue": "^3.20.0", diff --git a/packages/plugins/robot/src/Main.vue b/packages/plugins/robot/src/Main.vue index 912146efba..d51b00eb62 100644 --- a/packages/plugins/robot/src/Main.vue +++ b/packages/plugins/robot/src/Main.vue @@ -18,10 +18,12 @@ v-model:fullscreen="fullscreen" v-model:show="robotVisible" v-model:input="inputMessage" - :status="chatStatus" + :status="mappedStatus" + :chat-mode="robotSettingState.chatMode" :prompt-items="promptItems" :bubble-renderers="bubbleRenderers" :allowFiles="isVisualModel && robotSettingState.chatMode === ChatMode.Agent" + :show-aborted="robotSettingState.chatMode !== ChatMode.Agent" :beforeSubmit="checkApiKey" :promptClickHandler="promptClickHandler" @fileSelected="handleFileSelected" @@ -147,7 +149,7 @@ const showTeleport = ref(false) const showSetting = ref(false) const { - chatStatus, + mappedStatus, inputMessage, messages, changeChatMode, diff --git a/packages/plugins/robot/src/components/chat/RobotChat.vue b/packages/plugins/robot/src/components/chat/RobotChat.vue index ac07053681..8206bdccae 100644 --- a/packages/plugins/robot/src/components/chat/RobotChat.vue +++ b/packages/plugins/robot/src/components/chat/RobotChat.vue @@ -22,8 +22,18 @@ @item-click="handlePromptItemClick" > - - + + + + @@ -39,9 +49,6 @@ :showWordLimit="false" @submit="handleSendMessage" @cancel="handleAbortRequest" - :allowFiles="selectedAttachments.length < 1 && props.allowFiles" - uploadTooltip="支持上传1张图片" - @files-selected="handleSingleFilesSelected" > - @@ -74,11 +90,17 @@ import { TrSender, TrWelcome, TrAttachments, + UploadButton, + VoiceButton, + BubbleRenderers, + BubbleRendererMatchPriority, type BubbleRoleConfig, type PromptProps, - type RawFileAttachment + type RawFileAttachment, + type BubbleContentRendererMatch } from '@opentiny/tiny-robot' -import { type ChatMessage, GeneratingStatus } from '@opentiny/tiny-robot-kit' +import { type ChatMessage } from '@opentiny/tiny-robot-kit' +import { GeneratingStatus } from '../../constants/status' import { LoadingRenderer, MarkdownRenderer, ImgRenderer } from '../renderers' import { useNotify } from '@opentiny/tiny-engine-meta-register' @@ -91,10 +113,15 @@ const props = defineProps({ type: Function }, status: { type: String }, + chatMode: { type: String }, allowFiles: { type: Boolean, default: false }, + showAborted: { + type: Boolean, + default: true + }, bubbleRenderers: { type: Object as PropType>, default: () => ({}) @@ -113,6 +140,7 @@ const robotVisible = defineModel('show', { required: true }) const fullscreen = defineModel('fullscreen') const inputMessage = defineModel('input', { required: true }) const messages = defineModel('messages', { required: true }) +const senderRef = ref | null>(null) watch( () => props.allowFiles, @@ -123,6 +151,71 @@ watch( } ) +const contentRendererMatches = computed(() => [ + { + priority: BubbleRendererMatchPriority.LOADING, + find: (message) => Boolean(message.loading), + renderer: LoadingRenderer + }, + ...Object.entries(props.bubbleRenderers).map(([type, renderer]) => ({ + priority: BubbleRendererMatchPriority.NORMAL, + find: (_message: any, content: any) => content?.type === type, + renderer + })), + { + priority: BubbleRendererMatchPriority.NORMAL, + find: (message: any, content: any) => content?.type === 'tool' && message.tool_calls?.length, + renderer: BubbleRenderers.Tools + }, + { + priority: BubbleRendererMatchPriority.NORMAL, + find: (message: any, content: any) => + !message.loading && message.content && (!content?.type || ['markdown', 'text'].includes(content.type)), + renderer: MarkdownRenderer + }, + { + priority: BubbleRendererMatchPriority.NORMAL, + find: (message: any) => message?.content?.[0]?.type === 'img' || message?.content?.[0]?.type === 'image', + renderer: ImgRenderer + } +]) + +const isAgentMessage = (message: any) => { + const hasAgentContent = message.renderContent?.some((item: any) => { + return item.type === 'agent-content' || item.type === 'agent-loading' + }) + return message.metadata?.chatMode === 'agent' || hasAgentContent +} + +const resolveAgentRenderContent = (message: any) => { + if (!isAgentMessage(message) || message.role !== 'assistant') { + return message.renderContent + } + + const isLastMessage = messages.value.at(-1) === message + const isGenerating = Boolean(message.loading) || (isLastMessage && GeneratingStatus.includes(props.status as any)) + const renderContent = isGenerating + ? message.renderContent + : message.renderContent.filter((item: any) => item.type !== 'agent-loading') + const agentContents = renderContent.filter((item: any) => item.type === 'agent-content') + const finalStatus = agentContents.findLast((item: any) => ['success', 'failed', 'fix'].includes(item.status))?.status + + return renderContent.map((item: any) => { + if (item.type !== 'agent-content' || isGenerating) { + return item + } + + if (!item.status || item.status === 'loading') { + return { + ...item, + status: finalStatus || message.metadata?.agentStatus || 'failed' + } + } + + return item + }) +} + // 处理文件选择事件 const handleSingleFilesSelected = (files: File[] | null, retry = false) => { if (!files?.length) return @@ -178,32 +271,40 @@ const getSvgIcon = (name: string, style?: CSSProperties) => { const aiAvatar = getSvgIcon('AI') const welcomeIcon = getSvgIcon('AI', { fontSize: '44px' }) -const contentRenderers = computed(() => ({ - markdown: MarkdownRenderer, - loading: LoadingRenderer, - img: ImgRenderer, - ...props.bubbleRenderers -})) +const resolveMessageContent = (message: any) => { + if (Array.isArray(message.renderContent) && message.renderContent.length > 0) { + return resolveAgentRenderContent(message) + } -const roles: Record = { + if (isAgentMessage(message) && message.role === 'assistant' && message.content) { + const agentStatus = ['success', 'failed', 'fix'].includes(message.metadata?.agentStatus) + ? message.metadata.agentStatus + : 'failed' + return [ + { + type: 'agent-content', + status: agentStatus, + content: message.content + } + ] + } + + return message.content +} + +const roleConfigs: Record = { assistant: { placement: 'start', - avatar: aiAvatar, - contentRenderer: MarkdownRenderer, - customContentField: 'renderContent' + avatar: aiAvatar }, user: { - placement: 'end', - contentRenderer: MarkdownRenderer, - customContentField: 'renderContent' + placement: 'end' }, system: { hidden: true } } -const senderRef = ref | null>(null) - // 发送消息 const handleSendMessage = async (content: string) => { const messageContent = content || inputMessage.value @@ -256,6 +357,7 @@ const handleSendMessage = async (content: string) => { } messages.value.push(userMessage) inputMessage.value = '' + senderRef.value?.clear() selectedAttachments.value = [] emit('sendMessage') } @@ -371,13 +473,13 @@ const handlePromptItemClick = (ev: unknown, item: { description?: string }) => { } } :deep([data-role='user']) { - --tr-bubble-content-bg: var(--tr-color-primary-light); + --tr-bubble-box-bg: var(--tr-color-primary-light); } } &.fullscreen { :deep([data-role='assistant']) { - --tr-bubble-content-bg: transparent; + --tr-bubble-box-bg: transparent; .tr-bubble__content { padding: 8px 0 0; } @@ -491,4 +593,10 @@ const handlePromptItemClick = (ev: unknown, item: { description?: string }) => { .robot-bubble-list { height: 100%; } + +.aborted { + margin-top: 6px; + font-size: 12px; + opacity: 0.7; +} diff --git a/packages/plugins/robot/src/components/header-extension/History.vue b/packages/plugins/robot/src/components/header-extension/History.vue index efba3b013d..104f11f295 100644 --- a/packages/plugins/robot/src/components/header-extension/History.vue +++ b/packages/plugins/robot/src/components/header-extension/History.vue @@ -28,7 +28,7 @@ diff --git a/packages/plugins/robot/src/composables/core/pageUpdater.ts b/packages/plugins/robot/src/composables/core/pageUpdater.ts index 25084b87bd..4aca86bc5f 100644 --- a/packages/plugins/robot/src/composables/core/pageUpdater.ts +++ b/packages/plugins/robot/src/composables/core/pageUpdater.ts @@ -1,20 +1,37 @@ import { jsonrepair } from 'jsonrepair' import * as jsonpatch from 'fast-json-patch' import { utils } from '@opentiny/tiny-engine-utils' -import { useCanvas, useHistory } from '@opentiny/tiny-engine-meta-register' +import { useCanvas, useHistory, useMessage } from '@opentiny/tiny-engine-meta-register' import { useThrottleFn } from '@vueuse/core' import useModelConfig from './useConfig' import { ChatMode } from '../../types/mode.types' -import { fixMethods, schemaAutoFix, getJsonObjectString, isValidFastJsonPatch, jsonPatchAutoFix } from '../../utils' +import { + fixMethods, + schemaAutoFix, + getJsonObjectString, + isValidFastJsonPatch, + isValidSchemaChildren, + jsonPatchAutoFix +} from '../../utils' const { deepClone } = utils const logger = console +let schemaUpdateVersion = 0 +let lastSuccessfulSchema: object | null = null -const setSchema = (schema: object) => { - const { importSchema, setSaved } = useCanvas() - importSchema(schema) +const setSchema = async (schema: object, addHistory = false) => { + const { importSchema, pageState, setSaved } = useCanvas() + + if (addHistory) { + importSchema(schema) + useHistory().addHistory() + } else { + Object.assign(pageState.pageSchema, schema) + useMessage().publish({ topic: 'schemaChange', data: {} }) + } setSaved(false) + lastSuccessfulSchema = schema } type UpdateResult = @@ -22,11 +39,16 @@ type UpdateResult = | { isError: false; schema: object; error?: undefined } | { isError: true; schema?: undefined; error: unknown } -const _updatePageSchema = ( +const _updatePageSchema = async ( streamContent: string, currentPageSchema: object, - isFinal: boolean = false -): UpdateResult => { + isFinal: boolean = false, + version = schemaUpdateVersion +): Promise => { + if (version !== schemaUpdateVersion) { + return + } + const { getSelectedModelInfo } = useModelConfig() if (getSelectedModelInfo().config?.chatMode !== ChatMode.Agent) { return @@ -68,15 +90,46 @@ const _updatePageSchema = ( // schema纠错 fixMethods(newSchema.methods) + if (!isValidSchemaChildren(newSchema.children)) { + return { isError: true, error: 'format error: schema children contains invalid nodes.' } + } schemaAutoFix(newSchema.children) // 更新Schema - setSchema(newSchema) - if (isFinal) { - useHistory().addHistory() + try { + await setSchema(newSchema, isFinal) + } catch (error) { + if (isFinal) { + logger.error('set schema error:', error) + } + return { isError: true, error } } return { schema: newSchema, isError: false } } -export const updatePageSchema = useThrottleFn(_updatePageSchema, 200, true) +const updatePageSchemaThrottled = useThrottleFn(_updatePageSchema, 200, true) + +const invalidatePendingStreamUpdates = () => { + schemaUpdateVersion++ +} + +export const resetPageSchemaUpdateState = () => { + invalidatePendingStreamUpdates() + lastSuccessfulSchema = null +} + +export const getLastSuccessfulPageSchema = () => lastSuccessfulSchema + +export const updatePageSchema = ( + streamContent: string, + currentPageSchema: object, + isFinal: boolean = false +): UpdateResult | Promise => { + if (isFinal) { + invalidatePendingStreamUpdates() + return _updatePageSchema(streamContent, currentPageSchema, isFinal, schemaUpdateVersion) + } + + return updatePageSchemaThrottled(streamContent, currentPageSchema, isFinal, schemaUpdateVersion) +} diff --git a/packages/plugins/robot/src/composables/core/useConversation.ts b/packages/plugins/robot/src/composables/core/useConversation.ts index ae2d70869d..c5dec73ba3 100644 --- a/packages/plugins/robot/src/composables/core/useConversation.ts +++ b/packages/plugins/robot/src/composables/core/useConversation.ts @@ -1,9 +1,20 @@ -import { toRaw } from 'vue' -import { useConversation as useConversationKit, type UseMessageOptions } from '@opentiny/tiny-robot-kit' -import type { AIClient } from '@opentiny/tiny-robot-kit' +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' export interface ConversationAdapterOptions { - client: AIClient + provider: Pick // 业务回调函数 onStreamData: (data: any, messages: any[]) => void onFinishRequest: (finishReason: string, messages: any[], contextMessages: any[], messageState: any) => Promise @@ -20,54 +31,241 @@ export interface ConversationMetadata { [key: string]: any } +let currentConversationMetadata: ConversationMetadata = {} + +const createResponseProvider = ( + provider: Pick +): 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((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 || '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 的 useConversation 与业务逻辑解耦 + * 基于 tiny-robot-kit v0.4 的 useConversation/useMessage,对外保持旧业务层接口 */ export function useConversationAdapter(options: ConversationAdapterOptions) { - const { client, onStreamData, onFinishRequest, onMessageProcessed, statusManager } = options + const { provider, onStreamData, onFinishRequest, statusManager } = options - // 构建 events 适配器,连接业务回调 - const events: UseMessageOptions['events'] = { - onReceiveData: (data, messages, preventDefault) => { - preventDefault() - onStreamData(data, messages.value) - }, - async onFinish(finishReason, { messages, messageState }, preventDefault) { - preventDefault() + const storage: ConversationStorageStrategy = localStorageStrategyFactory() + const messageState = reactive({ + status: STATUS.FINISHED + }) + const emptyMessages = ref([]) + + const adapterPlugin: UseMessagePlugin = { + name: 'robot-conversation-adapter', + async onAfterRequest({ messages, lastChoice }) { if (statusManager.isProcessing()) { - // 无效场景,直接返回,例如返回流中出现了多次 [Done], 只响应第一次 return - } else { - statusManager.setProcessing() - } - const contextMessages = toRaw(messages.value.slice(0, -1)) - await onFinishRequest(finishReason ?? 'unknown', messages.value, contextMessages, messageState) - const lastMessage = messages.value.at(-1) - if (lastMessage && finishReason === 'stop' && !lastMessage.tool_calls && statusManager.isProcessing()) { - statusManager.resetProcessing() - await onMessageProcessed(finishReason ?? 'unknown', lastMessage.content ?? '', messages.value, {}) } + + 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 } } - // 使用 tiny-robot-kit 的 useConversation const { - messageManager, - state: conversationState, - ...conversationMethods + conversations, + activeConversationId, + activeConversation, + createConversation: createConversationKit, + switchConversation: switchConversationKit, + deleteConversation, + clear, + updateConversationTitle, + saveMessages, + abortActiveRequest } = useConversationKit({ - client, - events + 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) => { + void 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() + void 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) => { - return conversationMethods.createConversation(title, metadata) + 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 || {} + void saveConversation(conversation) + return currentId + } + } + + const conversation = createConversationKit({ title, metadata }) + currentConversationMetadata = conversation.metadata || {} + return conversation.id } /** @@ -75,34 +273,63 @@ export function useConversationAdapter(options: ConversationAdapterOptions) { * @param conversationId 会话ID * @param onStart 切换成功后的回调 */ - const switchConversation = (conversationId: string, onStart?: (state: any, messages: any, methods: any) => void) => { - const conversation = conversationState.conversations.find((c) => c.id === conversationId) - if (!conversation) return + 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 = conversationMethods.switchConversation(conversationId) + const result = await switchConversationKit(conversationId) - // 触发业务回调 - if (onStart) { - onStart(conversationState, messageManager.messages.value, conversationMethods) + 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 = conversationState.conversations.find((conversation) => conversation.id === currentId) - if (!currentConversation) return + const currentConversation = conversations.value.find((conversation) => conversation.id === currentId) + if (!currentConversation || currentId !== activeConversationId.value) { + return + } - const currentTitle = currentConversation?.title + const currentTitle = currentConversation.title if (currentTitle === defaultTitle && currentId) { - const messageContent = currentConversation.messages.find((item) => item.role === 'user')?.content + const messageContent = getActiveEngine()?.messages.value.find((item) => item.role === 'user')?.content const contentStr = typeof messageContent === 'string' ? messageContent : JSON.stringify(messageContent) - conversationMethods.updateTitle(currentId, contentStr.substring(0, 20)) + updateTitle(currentId, contentStr.substring(0, 20)) } } @@ -112,9 +339,7 @@ export function useConversationAdapter(options: ConversationAdapterOptions) { // 会话状态 conversationState, // 会话方法(包装后,覆盖原始方法) - ...conversationMethods, - createConversation, - switchConversation, + ...apis, autoSetTitle } } diff --git a/packages/plugins/robot/src/composables/core/useMessageStream.ts b/packages/plugins/robot/src/composables/core/useMessageStream.ts index 3699da0853..7357a9af82 100644 --- a/packages/plugins/robot/src/composables/core/useMessageStream.ts +++ b/packages/plugins/robot/src/composables/core/useMessageStream.ts @@ -16,19 +16,12 @@ export interface StreamDataHandlerOptions { } } -const handleDeltaReasoning = (choice: ChatCompletionStreamResponseChoice, lastMessage: Message) => { - if (typeof choice.delta.reasoning_content === 'string' && choice.delta.reasoning_content) { - if (lastMessage.renderContent.at(-1)?.contentType !== 'reasoning') { - lastMessage.renderContent.push({ - type: 'collapsible-text', - contentType: 'reasoning', - title: '深度思考', - content: '', - status: 'reasoning', - defaultOpen: true - }) - } - lastMessage.renderContent.at(-1)!.content += choice.delta.reasoning_content +const handleDeltaReasoning = ( + choice: ChatCompletionStreamResponseChoice, + lastMessage: Message, + reasoningAlreadyMerged = false +) => { + if (typeof choice.delta.reasoning_content === 'string' && choice.delta.reasoning_content && !reasoningAlreadyMerged) { lastMessage.reasoning_content = (lastMessage.reasoning_content || '') + choice.delta.reasoning_content } } @@ -39,15 +32,10 @@ const handleDeltaContent = ( contentType = 'markdown' ) => { if (typeof choice.delta.content === 'string' && choice.delta.content) { - if (lastMessage.renderContent.at(-1)?.contentType === 'reasoning') { - lastMessage.renderContent.at(-1)!.status = 'finish' - } if (lastMessage.renderContent.at(-1)?.type !== contentType) { lastMessage.renderContent.push({ type: contentType, content: '' }) - lastMessage.content = '' } lastMessage.renderContent.at(-1)!.content += choice.delta.content - lastMessage.content += choice.delta.content } } @@ -68,6 +56,24 @@ const handleDeltaToolCalls = (choice: ChatCompletionStreamResponseChoice, lastMe } } +const mergeUnprocessedDelta = (choice: ChatCompletionStreamResponseChoice, lastMessage: Message) => { + handleDeltaReasoning(choice, lastMessage) + if (typeof choice.delta.content === 'string' && choice.delta.content) { + lastMessage.content ||= '' + lastMessage.content += choice.delta.content + } + handleDeltaToolCalls(choice, lastMessage) +} + +const syncThinkingState = (choice: ChatCompletionStreamResponseChoice, lastMessage: Message) => { + if (typeof choice.delta.reasoning_content !== 'string') { + return + } + + lastMessage.state ||= {} + lastMessage.state.thinking = Boolean(choice.delta.reasoning_content) +} + /** * 创建流式数据处理器 * 通过依赖注入解耦业务逻辑与状态管理、回调函数 @@ -91,10 +97,11 @@ export function createStreamDataHandler(options: StreamDataHandlerOptions) { hooks.onStreamStart(messages) } - // 核心流式处理逻辑 - handleDeltaReasoning(choice, lastMessage) + if (!data.__contentAlreadyMerged) { + mergeUnprocessedDelta(choice, lastMessage) + } + syncThinkingState(choice, lastMessage) handleDeltaContent(choice, lastMessage, getContentType()) - handleDeltaToolCalls(choice, lastMessage) // 触发钩子 if (typeof choice.delta.content === 'string' && choice.delta.content) { diff --git a/packages/plugins/robot/src/composables/features/useToolCalls.ts b/packages/plugins/robot/src/composables/features/useToolCalls.ts index 191ec4a0a2..4959b63de0 100644 --- a/packages/plugins/robot/src/composables/features/useToolCalls.ts +++ b/packages/plugins/robot/src/composables/features/useToolCalls.ts @@ -1,8 +1,8 @@ import { toRaw } from 'vue' -import type { AIClient } from '@opentiny/tiny-robot-kit' import useMcpServer from './useMcp' import { serializeError } from '../../utils' import type { ResponseToolCall, RobotMessage, LLMMessage } from '../../types' +import type { OpenAICompatibleProvider } from '../../services/OpenAICompatibleProvider' const parseArgs = (args: string) => { try { @@ -55,7 +55,7 @@ export const callTools = async (tool_calls: any, hooks: CallToolHooks, signal: A // 工厂函数配置接口 export interface ToolCallHandlerConfig { - client: AIClient + provider: Pick getAbortController: () => AbortController formatMessages: (messages: any[]) => LLMMessage[] hooks: { @@ -86,7 +86,7 @@ export interface ToolCallHandlerConfig { * 使用工厂函数模式,将所有依赖通过配置注入 */ export function createToolCallHandler(config: ToolCallHandlerConfig) { - const { client, getAbortController, formatMessages, hooks, streamHandlers, getMessageState, statusManager } = config + const { provider, getAbortController, formatMessages, hooks, streamHandlers, getMessageState, statusManager } = config return async (tool_calls: ResponseToolCall[], messages: any[], contextMessages: RobotMessage[]) => { const hasToolCall = tool_calls?.length > 0 @@ -121,12 +121,19 @@ export function createToolCallHandler(config: ToolCallHandlerConfig) { return } - delete currentMessage.tool_calls + currentMessage.state ||= {} + currentMessage.state.toolsHandled = true + + messages.push({ + role: 'assistant', + content: '', + renderContent: [] + }) statusManager?.setProcessing() // 使用工具调用结果继续对话 - await client.chatStream( + await provider.chatStream( { messages: toolMessages as any, options: { signal: abortController.signal } }, { onData: (data) => streamHandlers.onData(data, messages), diff --git a/packages/plugins/robot/src/composables/modes/useAgentMode.ts b/packages/plugins/robot/src/composables/modes/useAgentMode.ts index 31b840b7ff..f7c12a9714 100644 --- a/packages/plugins/robot/src/composables/modes/useAgentMode.ts +++ b/packages/plugins/robot/src/composables/modes/useAgentMode.ts @@ -13,14 +13,14 @@ import { getMetaApi, META_SERVICE, useCanvas, useMaterial } from '@opentiny/tiny-engine-meta-register' import { utils } from '@opentiny/tiny-engine-utils' import { isValidJsonPatchObjectString, getRobotServiceOptions, removeLoading, addSystemPrompt } from '../../utils' -import { updatePageSchema } from '../core/pageUpdater' +import { getLastSuccessfulPageSchema, resetPageSchemaUpdateState, updatePageSchema } from '../core/pageUpdater' import useModelConfig from '../core/useConfig' import { formatComponents, getAgentSystemPrompt, getJsonFixPrompt } from '../../constants/prompts' import { search, fetchAssets } from '../../services/agentServices' -import { client } from '../../services/aiClient' +import { provider } from '../../services/aiClient' import type { ModeHooks } from '../../types/mode.types' import { ChatMode } from '../../types/mode.types' -import { STATUS, type MessageState } from '@opentiny/tiny-robot-kit' +import { STATUS, type MessageState } from '../../constants/status' const { deepClone } = utils const logger = console @@ -47,6 +47,50 @@ const updateToolCallRenderContent = (tool: Record, renderConten } } +const normalizeFinishedAgentMessages = (messages: any[]) => { + messages.forEach((message) => { + if (message.role !== 'assistant' || message.loading || !Array.isArray(message.renderContent)) { + return + } + + message.renderContent = message.renderContent.filter((item: any) => item.type !== 'agent-loading') + const agentContents = message.renderContent.filter((item: any) => item.type === 'agent-content') + const finalStatus = agentContents.findLast((item: any) => + ['success', 'failed', 'fix'].includes(item.status) + )?.status + + message.renderContent.forEach((item: any) => { + if (item.type === 'agent-content' && (!item.status || item.status === 'loading')) { + item.status = finalStatus || message.metadata?.agentStatus || 'failed' + } + }) + }) +} + +const markLastAgentContentFailed = (messages: any[], content: unknown) => { + const lastMessage = messages.at(-1) + if (!lastMessage) { + return + } + + lastMessage.loading = undefined + lastMessage.metadata = { + ...(lastMessage.metadata || {}), + chatMode: ChatMode.Agent, + agentStatus: 'failed' + } + lastMessage.renderContent ||= [] + lastMessage.renderContent = lastMessage.renderContent.filter((item: any) => item.type !== 'agent-loading') + + const lastAgentContent = lastMessage.renderContent.findLast((item: any) => item.type === 'agent-content') + const errorInfo = { content: content || '页面生成失败', status: 'failed' } + if (lastAgentContent) { + Object.assign(lastAgentContent, errorInfo) + } else { + lastMessage.renderContent.push({ type: 'agent-content', ...errorInfo }) + } +} + /** * Agent 模式实现 * 特点: @@ -74,9 +118,10 @@ export default function useAgentMode(): ModeHooks { // 确保会话元数据中记录为 Agent 模式 if (!conversation.metadata?.chatMode || conversation.metadata.chatMode !== ChatMode.Agent) { apis.updateMetadata(conversationState.currentId, { chatMode: ChatMode.Agent }) - apis.saveConversations() } + normalizeFinishedAgentMessages(messages) + // Agent 模式特殊处理:标记失败的 loading messages.at(-1)?.renderContent?.forEach((item: any) => { if (item.type.includes('loading') || item.status !== 'success') { @@ -86,6 +131,7 @@ export default function useAgentMode(): ModeHooks { } const onMessageSent = () => { + resetPageSchemaUpdateState() pageSchema = deepClone(useCanvas().pageState.pageSchema) } @@ -148,12 +194,7 @@ export default function useAgentMode(): ModeHooks { ) => { if (finishReason === 'aborted' || finishReason === 'error') { removeLoading(messages) - const errorInfo = { content: extraData?.error || '请求失败', status: 'failed' } - if (messages.at(-1).renderContent.at(-1)) { - Object.assign(messages.at(-1).renderContent.at(-1), errorInfo) - } else { - messages.at(-1).renderContent = [{ type: getContentType(), ...errorInfo }] - } + markLastAgentContentFailed(messages, extraData?.error || content || '请求失败') } } @@ -188,9 +229,12 @@ export default function useAgentMode(): ModeHooks { }: { abortControllerMap: Record; messageState: MessageState } ) => { const lastMessage = messages.at(-1) + const lastRenderContent = + lastMessage.renderContent.findLast((item: any) => item.type === getContentType()) || + lastMessage.renderContent.at(-1) if (finishReason === 'aborted' || finishReason === 'error') { - lastMessage.renderContent.at(-1).status = 'failed' + markLastAgentContentFailed(messages, content || '页面生成失败') return } @@ -214,8 +258,10 @@ export default function useAgentMode(): ModeHooks { return requestParams } const apiUrl = 'app-center/api/chat/completions' - lastMessage.renderContent.at(-1).status = 'fix' - const fixedResponse = await client.chat({ + if (lastRenderContent) { + lastRenderContent.status = 'fix' + } + const fixedResponse = await provider.chat({ messages: [{ role: 'user', content: getJsonFixPrompt(content, jsonValidResult.error) }], options: { signal: abortControllerMap.errorFix?.signal, beforeRequest: beforeRequest as any, apiUrl } }) @@ -225,7 +271,14 @@ export default function useAgentMode(): ModeHooks { } } catch (error) { logger.error('json fix failed', error) - lastMessage.renderContent.at(-1).status = 'failed' + if (lastRenderContent) { + lastRenderContent.status = 'failed' + } + lastMessage.metadata = { + ...(lastMessage.metadata || {}), + chatMode: ChatMode.Agent, + agentStatus: 'failed' + } if (error instanceof Error && error.message.includes('canceled')) { messageState.status = STATUS.ABORTED } else { @@ -240,11 +293,24 @@ export default function useAgentMode(): ModeHooks { // 更新页面 schema const result = await updatePageSchema(lastMessage.content, pageSchema, true) - if (result.schema) { - lastMessage.renderContent.at(-1).status = 'success' - lastMessage.renderContent.at(-1).schema = result.schema - } else { - lastMessage.renderContent.at(-1).status = 'failed' + const renderedSchema = result?.schema || getLastSuccessfulPageSchema() + if (renderedSchema) { + if (lastRenderContent) { + lastRenderContent.status = 'success' + lastRenderContent.schema = renderedSchema + } + lastMessage.metadata = { + ...(lastMessage.metadata || {}), + chatMode: ChatMode.Agent, + agentStatus: 'success' + } + } else if (lastRenderContent) { + lastRenderContent.status = 'failed' + lastMessage.metadata = { + ...(lastMessage.metadata || {}), + chatMode: ChatMode.Agent, + agentStatus: 'failed' + } } pageSchema = null diff --git a/packages/plugins/robot/src/composables/modes/useChatMode.ts b/packages/plugins/robot/src/composables/modes/useChatMode.ts index 7261a0f035..be39f48fda 100644 --- a/packages/plugins/robot/src/composables/modes/useChatMode.ts +++ b/packages/plugins/robot/src/composables/modes/useChatMode.ts @@ -16,29 +16,36 @@ import useMcpServer from '../features/useMcp' import type { ModeHooks } from '../../types/mode.types' import { ChatMode } from '../../types/mode.types' -const updateToolCallRenderContent = (tool: Record, renderContent: any[], { status, result } = {}) => { - const currentToolCallContent = renderContent.find((item) => item.type === 'tool' && item.toolCallId === tool.id) - if (currentToolCallContent) { - currentToolCallContent.status = status || 'running' - if (!currentToolCallContent.content) { - currentToolCallContent.content = {} - } - currentToolCallContent.content.params = tool.parsedArgs || tool.function!.arguments || {} - if (result) { - currentToolCallContent.content.result = result - } - } else { - renderContent.push({ - type: 'tool', - name: tool.name || tool.function!.name, - status: status || 'running', - content: { - params: tool.parsedArgs || tool.function!.arguments || {}, - ...(result ? { result } : {}) - }, - formatPretty: true, - toolCallId: tool.id - }) +const updateToolCallState = ( + tool: Record, + currentMessage: any, + { status, result }: { status?: string; result?: object | string } = {} +) => { + if (!tool.id) { + return + } + + currentMessage.state ||= {} + currentMessage.state.toolCall ||= {} + currentMessage.state.toolCall[tool.id as string] = { + ...(currentMessage.state.toolCall[tool.id as string] || {}), + status: status || 'running' + } + + if (result) { + currentMessage.state.toolCallResults ||= {} + currentMessage.state.toolCallResults[tool.id as string] = result + } +} + +const syncToolCallRenderContent = (currentMessage: any) => { + if (!currentMessage.tool_calls?.length) { + return + } + + currentMessage.renderContent ||= [] + if (!currentMessage.renderContent.some((item: any) => item.type === 'tool')) { + currentMessage.renderContent.push({ type: 'tool' }) } } @@ -58,8 +65,6 @@ export default function useChatMode(): ModeHooks { const getContentType = () => 'markdown' - const getLoadingType = () => 'loading' - // ========== 生命周期钩子 ========== const onConversationStart = (conversationState: any, messages: any[], apis: any) => { const conversation = conversationState.conversations.find((item: any) => item.id === conversationState.currentId) @@ -67,7 +72,6 @@ export default function useChatMode(): ModeHooks { // 确保会话元数据中记录为 Chat 模式 if (!conversation.metadata?.chatMode || conversation.metadata.chatMode !== ChatMode.Chat) { apis.updateMetadata(conversationState.currentId, { chatMode: ChatMode.Chat }) - apis.saveConversations() } // Chat 模式简单移除 loading @@ -116,18 +120,26 @@ export default function useChatMode(): ModeHooks { messages: any[], extraData?: Record ) => { - if (finishReason === 'aborted' || finishReason === 'error') { + if (finishReason === 'aborted') { + removeLoading(messages) + return + } + + if (finishReason === 'error') { removeLoading(messages) - messages.at(-1)!.renderContent.push({ type: 'text', content: serializeError(extraData?.error) }) + const errorContent = serializeError(extraData?.error) || '请求失败' + messages.at(-1)!.renderContent.push({ type: 'text', content: errorContent }) } } const onStreamTools = (tools: Record[], { currentMessage }: { currentMessage: any }) => { - tools.forEach((tool) => updateToolCallRenderContent(tool, currentMessage.renderContent)) + tools.forEach((tool) => updateToolCallState(tool, currentMessage)) + syncToolCallRenderContent(currentMessage) } const onBeforeCallTool = (tool: Record, { currentMessage }: { currentMessage: any }) => { - updateToolCallRenderContent(tool, currentMessage.renderContent) + updateToolCallState(tool, currentMessage) + syncToolCallRenderContent(currentMessage) } const onPostCallTool = ( @@ -136,11 +148,12 @@ export default function useChatMode(): ModeHooks { toolCallStatus: string, { currentMessage }: { currentMessage: any } ) => { - updateToolCallRenderContent(tool, currentMessage.renderContent, { status: toolCallStatus, result: toolCallResult }) + updateToolCallState(tool, currentMessage, { status: toolCallStatus, result: toolCallResult }) + syncToolCallRenderContent(currentMessage) } - const onPostCallTools = (_toolsResult: Record[], { currentMessage }: { currentMessage: any }) => { - currentMessage.renderContent.push({ type: getLoadingType(), content: '' }) + const onPostCallTools = (_toolsResult: Record[], _context: { currentMessage: any }) => { + // Chat 模式的工具调用由 BubbleRenderers.Tools 渲染;续写内容继续追加到同一个 markdown 块。 } const onMessageProcessed = async ( @@ -160,7 +173,7 @@ export default function useChatMode(): ModeHooks { // 配置方法 getApiUrl, getContentType, - getLoadingType, + getLoadingType: () => 'loading', // 生命周期钩子 onConversationStart, diff --git a/packages/plugins/robot/src/composables/useChat.ts b/packages/plugins/robot/src/composables/useChat.ts index 9ba4c505a1..08ba012a17 100644 --- a/packages/plugins/robot/src/composables/useChat.ts +++ b/packages/plugins/robot/src/composables/useChat.ts @@ -1,7 +1,8 @@ -import { nextTick, ref } from 'vue' -import { GeneratingStatus, STATUS, type ChatMessage, type MessageState } from '@opentiny/tiny-robot-kit' -import { formatMessages, removeLoading } from '../utils' -import { getClientConfig as getConfig, updateClientConfig as updateConfig, client } from '../services/aiClient' +import { nextTick, ref, computed } from 'vue' +import type { ChatMessage } from '@opentiny/tiny-robot-kit' +import { GeneratingStatus, STATUS, type MessageState } from '../constants/status' +import { formatMessages } from '../utils' +import { getClientConfig as getConfig, updateClientConfig as updateConfig, provider } from '../services/aiClient' import useModelConfig from './core/useConfig' import useMode from './modes/useMode' import { createStreamDataHandler } from './core/useMessageStream' @@ -14,7 +15,6 @@ const { // 配置方法 getApiUrl, getContentType, - getLoadingType, // 生命周期钩子 onConversationStart, onMessageSent, @@ -93,31 +93,58 @@ const handleFinishRequest = async ( messageState: MessageState ) => { const lastMessage = messages.at(-1) - - delete abortControllerMap.main - await onRequestEnd(finishReason, lastMessage.content, messages) // 本次请求结束 - - // 部分模型返回格式不太标准,例如finishReason没有返回tool_calls而是stop,这里做下兼容 - if (['tool_calls', 'stop'].includes(finishReason) && lastMessage.tool_calls?.length) { - lastMessage!.tool_calls.forEach((toolCall) => { - if (toolCall.type !== 'function') { - // 修复,兼容部分场景返回格式不标准,流式中多次返回type字段 - toolCall.type = 'function' - } - }) - await handleToolCall(lastMessage.tool_calls, messages, contextMessages) // eslint-disable-line + if (!lastMessage) { + chatStatus.value = CHAT_STATUS.FINISHED + return } - if (finishReason === 'aborted' || messageState?.status === STATUS.ABORTED) { - messageState.status = STATUS.ABORTED - } else if (finishReason === 'stop' && !lastMessage.tool_calls) { - messageState.status = STATUS.FINISHED + try { + delete abortControllerMap.main + lastMessage.loading = undefined + await onRequestEnd(finishReason, lastMessage.content, messages) // 本次请求结束 + + // 部分模型返回格式不太标准,例如finishReason没有返回tool_calls而是stop,这里做下兼容 + if ( + ['tool_calls', 'stop', 'unknown'].includes(finishReason) && + lastMessage.tool_calls?.length && + !lastMessage.state?.toolsHandled + ) { + lastMessage!.tool_calls.forEach((toolCall) => { + if (toolCall.type !== 'function') { + // 修复,兼容部分场景返回格式不标准,流式中多次返回type字段 + toolCall.type = 'function' + } + }) + await handleToolCall(lastMessage.tool_calls, messages, contextMessages) // eslint-disable-line + return + } + + if (finishReason === 'aborted' || messageState?.status === STATUS.ABORTED) { + messageState.status = STATUS.ABORTED + await onMessageProcessed('aborted', lastMessage.content ?? '', messages, { + abortControllerMap, + messageState + }) + } else if (!lastMessage.tool_calls?.length || lastMessage.state?.toolsHandled) { + messageState.status = STATUS.FINISHED + await onMessageProcessed(finishReason, lastMessage.content ?? '', messages, { + abortControllerMap, + messageState + }) + } + } finally { + if (GeneratingStatus.includes(messageState.status)) { + messageState.status = STATUS.FINISHED + } + const currentMessage = messages.at(-1) + if (currentMessage) { + currentMessage.loading = undefined + } chatStatus.value = CHAT_STATUS.FINISHED - await onMessageProcessed(finishReason, lastMessage.content ?? '', messages.value, {}) } } -const handleRequestError = async (error: Error, messages: ChatMessage[], messageState: MessageState) => { +const handleRequestError = async (error: unknown, messages: ChatMessage[], messageState: MessageState) => { chatStatus.value = CHAT_STATUS.FINISHED delete abortControllerMap.main await onRequestEnd('error', messages.at(-1).content, messages, { error }) // 本次请求结束 @@ -133,7 +160,7 @@ const { autoSetTitle: autoSetTitleBase, ...conversationMethods } = useConversationAdapter({ - client, + provider, onStreamData: handleStreamData, onFinishRequest: handleFinishRequest, onMessageProcessed: async (finishReason, content, messages) => { @@ -159,7 +186,7 @@ const { // 使用工厂函数创建工具调用处理器 const handleToolCall = createToolCallHandler({ - client, + provider, getAbortController: () => { abortControllerMap.toolCall = new AbortController() return abortControllerMap.toolCall @@ -187,8 +214,68 @@ const handleToolCall = createToolCallHandler({ } }) +const hasActiveRequest = () => { + return chatStatus.value !== CHAT_STATUS.FINISHED || Object.keys(abortControllerMap).length > 0 +} + +const abortRequest = () => { + Object.values(abortControllerMap).forEach((controller) => controller?.abort()) + for (const key of Object.keys(abortControllerMap)) { + delete abortControllerMap[key] + } + chatStatus.value = CHAT_STATUS.FINISHED + messageManager.messageState.status = STATUS.ABORTED + + void onRequestEnd( + 'aborted', + messageManager.messages.value.at(-1)?.content as string, + messageManager.messages.value + ).finally(() => { + if (conversationState.currentId) { + conversationMethods.saveMessages(conversationState.currentId) + } + }) +} + +const interruptActiveRequest = () => { + if (!hasActiveRequest()) { + return + } + + abortRequest() +} + +const restoreConversationMessagesState = (conversationId: string, messages: ChatMessage[]) => { + try { + const conversations = JSON.parse(localStorage.getItem('tiny-robot-ai-conversations') || '[]') + const conversation = conversations.find((item: any) => item.id === conversationId) + if (!conversation?.messages?.length) { + return + } + + messages.forEach((message: any, index) => { + const storedMessage = conversation.messages[index] + if (!storedMessage) { + return + } + + if (Array.isArray(storedMessage.renderContent) && storedMessage.renderContent.length) { + message.renderContent = storedMessage.renderContent + } + if (storedMessage.metadata?.agentStatus) { + message.metadata = { + ...(message.metadata || {}), + agentStatus: storedMessage.metadata.agentStatus + } + } + }) + } catch (error) { + // 忽略历史消息状态恢复失败,继续使用 tiny-robot-kit 加载出的消息。 + } +} + // 包装 conversation 方法,添加业务特定逻辑 -const createConversation = (title = '新会话', chatMode = robotSettingState.chatMode) => { +const createConversationWithMode = (title = '新会话', chatMode = robotSettingState.chatMode) => { const currentConversationId = conversationState.currentId! const newConversationId = createConversationBase(title, { chatMode }) if (newConversationId !== currentConversationId) { @@ -198,9 +285,27 @@ const createConversation = (title = '新会话', chatMode = robotSettingState.ch return newConversationId } +const createConversation = (title = '新会话', chatMode = robotSettingState.chatMode) => { + interruptActiveRequest() + return createConversationWithMode(title, chatMode) +} + const switchConversation = (conversationId: string) => { + interruptActiveRequest() onConversationEnd(conversationState.currentId!) return switchConversationBase(conversationId, (state, messages, methods) => { + const conversation = state.conversations.find((item: any) => item.id === state.currentId) + if (conversation?.metadata?.chatMode) { + updateChatModeState(conversation.metadata.chatMode) + updateConfig({ apiUrl: getApiUrl() }) + restoreConversationMessagesState(state.currentId, messages) + messages.forEach((message: any) => { + message.metadata = { + ...(message.metadata || {}), + chatMode: conversation.metadata.chatMode + } + }) + } onConversationStart(state, messages, methods) }) } @@ -220,23 +325,14 @@ const addMainAbortController = () => { abortControllerMap.main = mainAbortController } -const addLoading = (messages: ChatMessage[]) => { - const assistantMessage: ChatMessage = { - role: 'assistant', - content: '', - renderContent: [{ type: getLoadingType() }] - } - messages.push(assistantMessage) -} - const sendUserMessage = async () => { onMessageSent() await nextTick() + messageManager.messageState.status = STATUS.PENDING + messageManager.messageState.errorMsg = undefined addMainAbortController() - addLoading(messageManager.messages.value) await messageManager.send() if (messageManager.messageState.status === STATUS.ERROR) { - removeLoading(messageManager.messages.value) await handleRequestError( messageManager.messageState.errorMsg, messageManager.messages.value, @@ -246,31 +342,37 @@ const sendUserMessage = async () => { autoSetTitle() } -const abortRequest = () => { - Object.values(abortControllerMap).forEach((controller) => controller?.abort()) - for (const key of Object.keys(abortControllerMap)) { - delete abortControllerMap[key] +const changeChatMode = (chatMode: string) => { + if (chatMode === robotSettingState.chatMode) { + return } - chatStatus.value = CHAT_STATUS.FINISHED - onRequestEnd('aborted', messageManager.messages.value.at(-1)?.content as string, messageManager.messages.value) -} + interruptActiveRequest() + updateChatModeState(chatMode) -const changeChatMode = (chatMode: string) => { // 空会话更新metadata const usedConversationId = conversationState.currentId - const newConversationId = createConversation('新会话', chatMode) + const newConversationId = createConversationWithMode('新会话', chatMode) if (usedConversationId === newConversationId) { conversationMethods.updateMetadata(newConversationId, { chatMode }) - conversationMethods.saveConversations() } - updateChatModeState(chatMode) updateConfig({ apiUrl: getApiUrl() }) } +// 将 CHAT_STATUS 映射到 STATUS 枚举,用于 UI 显示 +const mappedStatus = computed(() => { + const statusMap: Record = { + [CHAT_STATUS.PROCESSING]: STATUS.PENDING, + [CHAT_STATUS.STREAMING]: STATUS.STREAMING, + [CHAT_STATUS.FINISHED]: STATUS.FINISHED + } + return statusMap[chatStatus.value] || STATUS.FINISHED +}) + export default function () { return { + mappedStatus, chatStatus, initChatClient, updateConfig, diff --git a/packages/plugins/robot/src/constants/index.ts b/packages/plugins/robot/src/constants/index.ts index 504e83352d..434f4f7d1f 100644 --- a/packages/plugins/robot/src/constants/index.ts +++ b/packages/plugins/robot/src/constants/index.ts @@ -1 +1,2 @@ export * from './model-config' +export * from './status' diff --git a/packages/plugins/robot/src/constants/status.ts b/packages/plugins/robot/src/constants/status.ts new file mode 100644 index 0000000000..e8400ff540 --- /dev/null +++ b/packages/plugins/robot/src/constants/status.ts @@ -0,0 +1,28 @@ +/** + * 状态常量定义 + * 保持与 tiny-robot v0.3.x 兼容,用于状态管理 + */ + +/** + * 消息状态枚举 + */ +export enum STATUS { + PENDING = 'pending', + STREAMING = 'streaming', + FINISHED = 'finished', + ERROR = 'error', + ABORTED = 'aborted' +} + +/** + * 生成中的状态列表 + */ +export const GeneratingStatus: STATUS[] = [STATUS.PENDING, STATUS.STREAMING] + +/** + * 消息状态接口 + */ +export interface MessageState { + status: STATUS + errorMsg?: unknown +} diff --git a/packages/plugins/robot/src/services/aiClient.ts b/packages/plugins/robot/src/services/aiClient.ts index 0c09c2f7e6..a7725827a3 100644 --- a/packages/plugins/robot/src/services/aiClient.ts +++ b/packages/plugins/robot/src/services/aiClient.ts @@ -1,21 +1,8 @@ -import { AIClient } from '@opentiny/tiny-robot-kit' import { OpenAICompatibleProvider, type ProviderConfig } from './OpenAICompatibleProvider' -const createClient = (config: ProviderConfig) => { - const provider: OpenAICompatibleProvider = new OpenAICompatibleProvider(config) - - const client: AIClient = new AIClient({ - ...config, - provider: 'custom', - providerImplementation: provider - }) - - return { client, provider } -} - -const { client, provider } = createClient({} as ProviderConfig) +const provider = new OpenAICompatibleProvider({} as ProviderConfig) const getClientConfig: () => ProviderConfig = provider.getBaseConfig.bind(provider) const updateClientConfig: (config: ProviderConfig) => void = provider.updateConfig.bind(provider) -export { client, getClientConfig, updateClientConfig } +export { provider, getClientConfig, updateClientConfig } diff --git a/packages/plugins/robot/src/utils/chat.utils.ts b/packages/plugins/robot/src/utils/chat.utils.ts index 557641dd79..702a2cc343 100644 --- a/packages/plugins/robot/src/utils/chat.utils.ts +++ b/packages/plugins/robot/src/utils/chat.utils.ts @@ -17,6 +17,9 @@ export const formatMessages = (messages: LLMMessage[]) => { } export const serializeError = (err: unknown): string => { + if (err === undefined || err === null) { + return '' + } if (err instanceof Error) { return JSON.stringify({ name: err.name, message: err.message }) } diff --git a/packages/plugins/robot/src/utils/schema.utils.ts b/packages/plugins/robot/src/utils/schema.utils.ts index 72e031b4aa..a9103de62a 100644 --- a/packages/plugins/robot/src/utils/schema.utils.ts +++ b/packages/plugins/robot/src/utils/schema.utils.ts @@ -26,7 +26,7 @@ export const fixIconComponent = (data: any) => { /** * 检查是否为纯对象 */ -const isPlainObject = (value: unknown) => +export const isPlainObject = (value: unknown) => typeof value === 'object' && value !== null && Object.prototype.toString.call(value) === '[object Object]' /** @@ -128,6 +128,18 @@ export const isValidFastJsonPatch = (patch: any): boolean => { return false } +const isValidSchemaNode = (node: unknown): boolean => { + if (!isPlainObject(node)) { + return false + } + + const children = (node as { children?: unknown }).children + return !Array.isArray(children) || children.every(isValidSchemaNode) +} + +export const isValidSchemaChildren = (children: unknown): boolean => + !Array.isArray(children) || children.every(isValidSchemaNode) + /** * 自动修复JSON Patch数组,过滤无效操作 */ @@ -146,7 +158,17 @@ export const jsonPatchAutoFix = (jsonPatches: any[], isFinial: boolean) => { export const getJsonObjectString = (streamContent: string): string => { const regex = /```(json|schema)?([\s\S]*?)```/ const match = streamContent.match(regex) - return (match && match[2]) || streamContent + if (match?.[2]) { + return match[2] + } + + const arrayStart = streamContent.indexOf('[') + const arrayEnd = streamContent.lastIndexOf(']') + if (arrayStart !== -1 && arrayEnd > arrayStart) { + return streamContent.slice(arrayStart, arrayEnd + 1) + } + + return streamContent } /** @@ -155,7 +177,12 @@ export const getJsonObjectString = (streamContent: string): string => { export const isValidJsonPatchObjectString = (streamContent: string) => { const jsonString = getJsonObjectString(streamContent) try { - const data = JSON.parse(jsonString) + let data + try { + data = JSON.parse(jsonString) + } catch { + data = JSON.parse(jsonrepair(jsonString)) + } if (!isValidFastJsonPatch(data)) { return { isError: true, diff --git a/packages/plugins/robot/test/composables/core/pageUpdater.test.ts b/packages/plugins/robot/test/composables/core/pageUpdater.test.ts new file mode 100644 index 0000000000..699d75433e --- /dev/null +++ b/packages/plugins/robot/test/composables/core/pageUpdater.test.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { canvasState, resetCanvasState } from '../../mocks/meta-register' + +vi.mock('@opentiny/tiny-engine-utils', () => ({ + utils: { + deepClone: (value: unknown) => JSON.parse(JSON.stringify(value)) + } +})) + +vi.mock('@opentiny/vue-icon', () => ({ + default: { + IconWarning: {} + } +})) + +vi.mock('../../../src/composables/core/useConfig', () => ({ + default: () => ({ + getSelectedModelInfo: () => ({ + config: { + chatMode: 'agent' + } + }) + }) +})) + +const getBaseSchema = () => ({ + componentName: 'Page', + props: {}, + state: {}, + methods: {}, + css: '', + children: [] +}) + +const addButtonPatch = () => + JSON.stringify([ + { + op: 'add', + path: '/children/0', + value: { + componentName: 'TinyButton', + props: { + text: 'Submit' + }, + children: [] + } + } + ]) + +describe('pageUpdater', () => { + beforeEach(async () => { + resetCanvasState() + canvasState.pageSchema = getBaseSchema() + const { resetPageSchemaUpdateState } = await import('../../../src/composables/core/pageUpdater') + resetPageSchemaUpdateState() + }) + + it('applies streaming updates without importing the whole schema', async () => { + const { updatePageSchema } = await import('../../../src/composables/core/pageUpdater') + const initialSchema = getBaseSchema() + + const result = await updatePageSchema(addButtonPatch(), initialSchema, false) + + expect(result?.schema?.children).toHaveLength(1) + expect(canvasState.pageSchema.children).toHaveLength(1) + expect(canvasState.pageSchema.children[0].componentName).toBe('TinyButton') + expect(canvasState.imported).toHaveLength(0) + expect(canvasState.history).toHaveLength(0) + expect(canvasState.saved).toBe(false) + expect(canvasState.published).toEqual([{ topic: 'schemaChange', data: {} }]) + }) + + it('imports and records history for final updates', async () => { + const { updatePageSchema } = await import('../../../src/composables/core/pageUpdater') + const initialSchema = getBaseSchema() + + const result = await updatePageSchema(addButtonPatch(), initialSchema, true) + + expect(result?.schema?.children).toHaveLength(1) + expect(canvasState.imported).toHaveLength(1) + expect(canvasState.imported[0].children[0].componentName).toBe('TinyButton') + expect(canvasState.history).toHaveLength(1) + expect(canvasState.published).toHaveLength(0) + expect(canvasState.saved).toBe(false) + }) + + it('stores the last successful streaming schema as a final fallback', async () => { + const { getLastSuccessfulPageSchema, updatePageSchema } = await import('../../../src/composables/core/pageUpdater') + const initialSchema = getBaseSchema() + + await updatePageSchema(addButtonPatch(), initialSchema, false) + const fallbackBeforeFinal = getLastSuccessfulPageSchema() + const finalResult = await updatePageSchema('not json', initialSchema, true) + + expect(finalResult?.isError).toBe(true) + expect(getLastSuccessfulPageSchema()).toBe(fallbackBeforeFinal) + expect(getLastSuccessfulPageSchema()?.children[0].componentName).toBe('TinyButton') + }) + + it('clears the fallback only when a new agent turn starts', async () => { + const { getLastSuccessfulPageSchema, resetPageSchemaUpdateState, updatePageSchema } = await import( + '../../../src/composables/core/pageUpdater' + ) + const initialSchema = getBaseSchema() + + await updatePageSchema(addButtonPatch(), initialSchema, false) + expect(getLastSuccessfulPageSchema()).toBeTruthy() + + resetPageSchemaUpdateState() + + expect(getLastSuccessfulPageSchema()).toBeNull() + }) + + it('keeps the final schema after an earlier streaming update has completed', async () => { + const { updatePageSchema } = await import('../../../src/composables/core/pageUpdater') + const initialSchema = getBaseSchema() + + const streamingResult = await updatePageSchema(addButtonPatch(), initialSchema, false) + const finalResult = await updatePageSchema( + JSON.stringify([ + { + op: 'add', + path: '/children/0', + value: { + componentName: 'TinyForm', + props: {}, + children: [] + } + } + ]), + initialSchema, + true + ) + + expect(streamingResult?.schema?.children[0].componentName).toBe('TinyButton') + expect(finalResult?.schema?.children[0].componentName).toBe('TinyForm') + expect(canvasState.pageSchema.children[0].componentName).toBe('TinyForm') + }) + + it('rejects schemas containing invalid children nodes before touching canvas', async () => { + const { updatePageSchema } = await import('../../../src/composables/core/pageUpdater') + const initialSchema = getBaseSchema() + + const result = await updatePageSchema( + JSON.stringify([ + { + op: 'add', + path: '/children/0', + value: 'broken child' + } + ]), + initialSchema, + true + ) + + expect(result?.isError).toBe(true) + expect(canvasState.imported).toHaveLength(0) + expect(canvasState.pageSchema.children).toHaveLength(0) + }) + + it('normalizes invalid methods before updating canvas', async () => { + const { updatePageSchema } = await import('../../../src/composables/core/pageUpdater') + const initialSchema = getBaseSchema() + + const result = await updatePageSchema( + JSON.stringify([ + { + op: 'add', + path: '/methods/submit', + value: { + type: 'JSExpression', + value: 'this.submit()' + } + } + ]), + initialSchema, + true + ) + + expect(result?.schema?.methods.submit.type).toBe('JSFunction') + expect(result?.schema?.methods.submit.value).toContain('function submit()') + expect(canvasState.pageSchema.methods.submit.type).toBe('JSFunction') + }) +}) diff --git a/packages/plugins/robot/test/mocks/meta-register.ts b/packages/plugins/robot/test/mocks/meta-register.ts new file mode 100644 index 0000000000..446763a6cc --- /dev/null +++ b/packages/plugins/robot/test/mocks/meta-register.ts @@ -0,0 +1,45 @@ +export const canvasState = { + pageSchema: null as any, + saved: true, + imported: [] as any[], + history: [] as any[], + published: [] as any[] +} + +export const resetCanvasState = () => { + canvasState.pageSchema = null + canvasState.saved = true + canvasState.imported = [] + canvasState.history = [] + canvasState.published = [] +} + +export const useCanvas = () => ({ + pageState: { + get pageSchema() { + return canvasState.pageSchema + }, + set pageSchema(value) { + canvasState.pageSchema = value + } + }, + importSchema: (schema: any) => { + canvasState.imported.push(JSON.parse(JSON.stringify(schema))) + canvasState.pageSchema = schema + }, + setSaved: (saved: boolean) => { + canvasState.saved = saved + } +}) + +export const useHistory = () => ({ + addHistory: () => { + canvasState.history.push(JSON.parse(JSON.stringify(canvasState.pageSchema))) + } +}) + +export const useMessage = () => ({ + publish: (event: any) => { + canvasState.published.push(event) + } +}) diff --git a/packages/plugins/robot/test/utils/schema.utils.test.ts b/packages/plugins/robot/test/utils/schema.utils.test.ts new file mode 100644 index 0000000000..141fd6692e --- /dev/null +++ b/packages/plugins/robot/test/utils/schema.utils.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@opentiny/vue-icon', () => ({ + default: { + IconWarning: {} + } +})) + +import { + fixComponentName, + fixMethods, + getJsonObjectString, + isPlainObject, + isValidFastJsonPatch, + isValidJsonPatchObjectString, + isValidOperation, + isValidSchemaChildren, + jsonPatchAutoFix, + schemaAutoFix +} from '../../src/utils/schema.utils' + +const createComponent = (componentName = 'div', children: any[] = []) => ({ + componentName, + props: {}, + children +}) + +describe('schema utils', () => { + describe('isPlainObject', () => { + it('accepts regular object literals', () => { + expect(isPlainObject({})).toBe(true) + expect(isPlainObject({ componentName: 'TinyButton' })).toBe(true) + }) + + it('rejects arrays, null, and primitive values', () => { + expect(isPlainObject([])).toBe(false) + expect(isPlainObject(null)).toBe(false) + expect(isPlainObject('schema')).toBe(false) + expect(isPlainObject(1)).toBe(false) + expect(isPlainObject(false)).toBe(false) + }) + }) + + describe('getJsonObjectString', () => { + it('extracts json from fenced markdown blocks', () => { + const content = [ + 'AI result:', + '```json', + '[{"op":"add","path":"/children/0","value":{"componentName":"div"}}]', + '```', + 'done' + ].join('\n') + + expect(getJsonObjectString(content).trim()).toBe( + '[{"op":"add","path":"/children/0","value":{"componentName":"div"}}]' + ) + }) + + it('extracts json from schema fenced markdown blocks', () => { + const content = ['```schema', '[{"op":"replace","path":"/css","value":".foo{color:red}"}]', '```'].join('\n') + + expect(getJsonObjectString(content).trim()).toBe('[{"op":"replace","path":"/css","value":".foo{color:red}"}]') + }) + + it('extracts the json patch array from surrounding prose', () => { + const content = + '好的,以下是更新内容:[{"op":"add","path":"/children/0","value":{"componentName":"TinyForm"}}],页面已生成。' + + expect(getJsonObjectString(content)).toBe( + '[{"op":"add","path":"/children/0","value":{"componentName":"TinyForm"}}]' + ) + }) + + it('returns the original content when no json array is present', () => { + const content = 'no patch content' + + expect(getJsonObjectString(content)).toBe(content) + }) + + it('uses the outermost array when prose contains nested arrays in values', () => { + const content = + 'patch: [{"op":"add","path":"/children/0","value":{"componentName":"TinySelect","props":{"options":[1,2,3]}}}] end' + + expect(getJsonObjectString(content)).toBe( + '[{"op":"add","path":"/children/0","value":{"componentName":"TinySelect","props":{"options":[1,2,3]}}}]' + ) + }) + }) + + describe('isValidOperation', () => { + it('validates add, replace, and test operations with value', () => { + expect(isValidOperation({ op: 'add', path: '/children/0', value: createComponent() })).toBe(true) + expect(isValidOperation({ op: 'replace', path: '/css', value: '.page{}' })).toBe(true) + expect(isValidOperation({ op: 'test', path: '/componentName', value: 'Page' })).toBe(true) + }) + + it('validates move and copy operations with from', () => { + expect(isValidOperation({ op: 'move', path: '/children/1', from: '/children/0' })).toBe(true) + expect(isValidOperation({ op: 'copy', path: '/children/1', from: '/children/0' })).toBe(true) + }) + + it('allows internal _get operations used by the robot prompt flow', () => { + expect(isValidOperation({ op: '_get', path: '/children' })).toBe(true) + }) + + it('rejects operations with unsupported op, missing path, or missing payload', () => { + expect(isValidOperation(null)).toBe(false) + expect(isValidOperation('patch')).toBe(false) + expect(isValidOperation({ op: 'merge', path: '/children' })).toBe(false) + expect(isValidOperation({ op: 'add', value: createComponent() })).toBe(false) + expect(isValidOperation({ op: 'replace', path: '/css' })).toBe(false) + expect(isValidOperation({ op: 'copy', path: '/children/1' })).toBe(false) + }) + }) + + describe('isValidFastJsonPatch', () => { + it('accepts a single operation or an operation array', () => { + const patch = { op: 'add', path: '/children/0', value: createComponent() } + + expect(isValidFastJsonPatch(patch)).toBe(true) + expect(isValidFastJsonPatch([patch])).toBe(true) + }) + + it('rejects arrays containing invalid operations', () => { + expect( + isValidFastJsonPatch([ + { op: 'add', path: '/children/0', value: createComponent() }, + { op: 'replace', path: '/css' } + ]) + ).toBe(false) + }) + }) + + describe('jsonPatchAutoFix', () => { + it('keeps all valid patches for final updates', () => { + const patches = [ + { op: 'add', path: '/children/0', value: createComponent('TinyForm') }, + { op: 'replace', path: '/state/title', value: 'Survey' }, + { op: 'replace', path: '/css', value: '.survey{}' } + ] + + expect(jsonPatchAutoFix(patches, true)).toEqual(patches) + }) + + it('keeps complete leading patches while streaming', () => { + const patches = [ + { op: 'replace', path: '/state/title', value: 'Survey' }, + { op: 'add', path: '/children/0', value: createComponent('TinyForm') } + ] + + expect(jsonPatchAutoFix(patches, false)).toEqual(patches) + }) + + it('drops the last streaming patch when it is not a children patch', () => { + const patches = [ + { op: 'add', path: '/children/0', value: createComponent('TinyForm') }, + { op: 'replace', path: '/css', value: '.partial{' } + ] + + expect(jsonPatchAutoFix(patches, false)).toEqual([patches[0]]) + }) + + it('filters invalid operations after applying the streaming last-patch rule', () => { + const validPatch = { op: 'add', path: '/children/0', value: createComponent('TinyForm') } + const invalidPatch = { op: 'replace', path: '/css' } + + expect(jsonPatchAutoFix([validPatch, invalidPatch], true)).toEqual([validPatch]) + }) + + it('keeps a trailing streaming children patch because it can render progressively', () => { + const patches = [ + { op: 'replace', path: '/state/title', value: 'Survey' }, + { op: 'add', path: '/children/1', value: createComponent('TinyButton') } + ] + + expect(jsonPatchAutoFix(patches, false)).toEqual(patches) + }) + }) + + describe('isValidSchemaChildren', () => { + it('accepts missing children and arrays of schema nodes', () => { + expect(isValidSchemaChildren(undefined)).toBe(true) + expect(isValidSchemaChildren([createComponent('TinyForm')])).toBe(true) + expect(isValidSchemaChildren([createComponent('TinyForm', [createComponent('TinyInput')])])).toBe(true) + }) + + it('rejects string nodes that would break canvas node-map generation', () => { + expect(isValidSchemaChildren(['broken node'])).toBe(false) + expect(isValidSchemaChildren([createComponent('TinyForm', ['broken child'])])).toBe(false) + }) + + it('rejects null and primitive nested children while allowing empty arrays', () => { + expect(isValidSchemaChildren([createComponent('TinyForm', [])])).toBe(true) + expect(isValidSchemaChildren([createComponent('TinyForm', [null])])).toBe(false) + expect(isValidSchemaChildren([createComponent('TinyForm', [1])])).toBe(false) + expect(isValidSchemaChildren([createComponent('TinyForm', [false])])).toBe(false) + }) + }) + + describe('isValidJsonPatchObjectString', () => { + it('accepts valid json patch content', () => { + const result = isValidJsonPatchObjectString( + '[{"op":"add","path":"/children/0","value":{"componentName":"TinyButton"}}]' + ) + + expect(result.isError).toBe(false) + }) + + it('repairs slightly incomplete json before validating', () => { + const result = isValidJsonPatchObjectString( + '[{"op":"add","path":"/children/0","value":{"componentName":"TinyButton"}}' + ) + + expect(result.isError).toBe(false) + }) + + it('rejects json that is not a valid patch operation', () => { + const result = isValidJsonPatchObjectString('[{"path":"/children/0"}]') + + expect(result.isError).toBe(true) + }) + }) + + describe('schemaAutoFix', () => { + it('adds a default component name to plain schema nodes', () => { + const node = { props: {}, children: [{ props: {} }] } + + schemaAutoFix(node) + + expect(node.componentName).toBe('div') + expect(node.children[0].componentName).toBe('div') + }) + + it('does not treat json patch operations as schema nodes', () => { + const patch = { op: 'add', path: '/children/0', value: createComponent() } + + schemaAutoFix(patch) + + expect(patch).not.toHaveProperty('componentName') + }) + + it('recursively keeps existing component names when fixing children', () => { + const node = createComponent('TinyForm', [createComponent('TinyFormItem'), { props: {} }]) + + schemaAutoFix(node) + + expect(node.componentName).toBe('TinyForm') + expect(node.children[0].componentName).toBe('TinyFormItem') + expect(node.children[1].componentName).toBe('div') + }) + }) + + describe('fixMethods', () => { + it('keeps valid JSFunction methods untouched', () => { + const methods = { + submit: { + type: 'JSFunction', + value: 'function submit() {\n return true;\n}' + } + } + + fixMethods(methods) + + expect(methods.submit.value).toContain('return true') + }) + + it('replaces invalid method definitions with safe placeholders', () => { + const methods = { + submit: { + type: 'JSExpression', + value: 'this.submit()' + } + } + + fixMethods(methods) + + expect(methods.submit.type).toBe('JSFunction') + expect(methods.submit.value).toContain('function submit()') + }) + }) +}) diff --git a/packages/plugins/robot/vitest.config.ts b/packages/plugins/robot/vitest.config.ts new file mode 100644 index 0000000000..ff1d2047dc --- /dev/null +++ b/packages/plugins/robot/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config' +import path from 'path' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/**/*.test.ts'] + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + '@opentiny/tiny-engine-meta-register': path.resolve(__dirname, './test/mocks/meta-register.ts') + } + } +})