diff --git a/designer-demo/src/composable/http/index.js b/designer-demo/src/composable/http/index.js index 633ccb1bc1..141c885c7b 100644 --- a/designer-demo/src/composable/http/index.js +++ b/designer-demo/src/composable/http/index.js @@ -16,6 +16,7 @@ const procession = { let loginVM = null const showError = (url, message) => { + if (message === 'canceled') return // 取消请求场景不报错 globalNotify({ type: 'error', title: '接口报错', diff --git a/docs/extension-capabilities-tutorial/ai-plugin-configuration.md b/docs/extension-capabilities-tutorial/ai-plugin-configuration.md index 90b91e934d..18913de1e0 100644 --- a/docs/extension-capabilities-tutorial/ai-plugin-configuration.md +++ b/docs/extension-capabilities-tutorial/ai-plugin-configuration.md @@ -16,7 +16,7 @@ ### 前端代码改动 -在`tiny-engine/packages/plugins/robot/src/js/useRobot.ts`文件改动如下 +在`tiny-engine/packages/plugins/robot/src/composables/useRobot.ts`文件改动如下 ![](./imgs/ai-image23.png) diff --git a/packages/common/js/completion.js b/packages/common/js/completion.js index 2ce64a8e08..08ba04ec57 100644 --- a/packages/common/js/completion.js +++ b/packages/common/js/completion.js @@ -218,7 +218,7 @@ const fetchAiInlineCompletion = (codeBeforeCursor, codeAfterCursor) => { { headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}` + Authorization: `Bearer ${apiKey || ''}` } } ) diff --git a/packages/configurator/src/select-icon-configurator/SelectIconConfigurator.vue b/packages/configurator/src/select-icon-configurator/SelectIconConfigurator.vue index 231506f04d..42cd457444 100644 --- a/packages/configurator/src/select-icon-configurator/SelectIconConfigurator.vue +++ b/packages/configurator/src/select-icon-configurator/SelectIconConfigurator.vue @@ -54,11 +54,11 @@ export default { iconSearchValue: '', icon: { name: props.modelValue, - component: props.modelValue && SvgICons[props.modelValue]() + component: props.modelValue && SvgICons[props.modelValue]?.() }, defaultIcon: { name: props.modelValue, - component: props.modelValue && SvgICons[props.modelValue]() + component: props.modelValue && SvgICons[props.modelValue]?.() } }) diff --git a/packages/layout/src/DesignSettings.vue b/packages/layout/src/DesignSettings.vue index 5458365d62..7bbe2c29bd 100644 --- a/packages/layout/src/DesignSettings.vue +++ b/packages/layout/src/DesignSettings.vue @@ -201,7 +201,6 @@ export default { display: flex; flex-direction: column; position: absolute; - top: var(--base-top-panel-height); right: var(--base-nav-panel-width); z-index: 999; diff --git a/packages/layout/src/Main.vue b/packages/layout/src/Main.vue index b8ff93568e..0a3cd64899 100644 --- a/packages/layout/src/Main.vue +++ b/packages/layout/src/Main.vue @@ -154,6 +154,7 @@ export default { display: flex; flex-flow: row nowrap; z-index: 4; + position: relative; } :deep(.monaco-editor .suggest-widget) { border-width: 0; diff --git a/packages/plugins/resource/src/ResourceList.vue b/packages/plugins/resource/src/ResourceList.vue index 7fbbf51c44..e384382451 100644 --- a/packages/plugins/resource/src/ResourceList.vue +++ b/packages/plugins/resource/src/ResourceList.vue @@ -427,6 +427,7 @@ export default { const params = addSourceData.value.map((item) => { return { name: item.name, + description: item.description || '', resourceGroupId: state.group.id, resourceData: item?.resourceData || '', resourceUrl: item?.resourceUrl || '', diff --git a/packages/plugins/robot/assets/failed.svg b/packages/plugins/robot/assets/failed.svg new file mode 100644 index 0000000000..2c96071046 --- /dev/null +++ b/packages/plugins/robot/assets/failed.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/plugins/robot/assets/success.svg b/packages/plugins/robot/assets/success.svg new file mode 100644 index 0000000000..1572769068 --- /dev/null +++ b/packages/plugins/robot/assets/success.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/plugins/robot/index.ts b/packages/plugins/robot/index.ts index ef4fba0da0..2f0d102110 100644 --- a/packages/plugins/robot/index.ts +++ b/packages/plugins/robot/index.ts @@ -14,7 +14,7 @@ import entry from './src/Main.vue' import metaData from './meta' import './src/styles/vars.less' import '@opentiny/tiny-robot/dist/style.css' -import { RobotService } from './src/js/index' +import { RobotService } from './src/metas' export default { ...metaData, diff --git a/packages/plugins/robot/meta.js b/packages/plugins/robot/meta.js index ee2bbb126d..fbc103faa5 100644 --- a/packages/plugins/robot/meta.js +++ b/packages/plugins/robot/meta.js @@ -6,6 +6,9 @@ export default { icon: { default: 'AI' }, - renderType: 'icon' + renderType: 'icon', + customCompatibleAIModels: [], // 模型配置 + enableResourceContext: true, // 提示词上下文携带资源插件图片 + enableRagContext: false // 提示词上下文携带查询到的知识库内容 } } diff --git a/packages/plugins/robot/mock/test.ts b/packages/plugins/robot/mock/test.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/plugins/robot/package.json b/packages/plugins/robot/package.json index c050b84229..8f3acbe8ef 100644 --- a/packages/plugins/robot/package.json +++ b/packages/plugins/robot/package.json @@ -25,18 +25,19 @@ "license": "MIT", "homepage": "https://opentiny.design/tiny-engine", "dependencies": { - "@opentiny/tiny-engine-meta-register": "workspace:*", "@opentiny/tiny-engine-common": "workspace:*", + "@opentiny/tiny-engine-meta-register": "workspace:*", "@opentiny/tiny-engine-utils": "workspace:*", - "@opentiny/tiny-robot": "0.3.0-rc.0", - "@opentiny/tiny-robot-kit": "0.3.0-rc.0", - "@opentiny/tiny-robot-svgs": "0.3.0-rc.0", + "@opentiny/tiny-robot": "0.3.0-rc.5", + "@opentiny/tiny-robot-kit": "0.3.0-rc.5", + "@opentiny/tiny-robot-svgs": "0.3.0-rc.5", "@opentiny/tiny-schema-renderer": "1.0.0-beta.6", - "fast-json-patch": "~3.1.1", + "@vueuse/core": "^9.13.0", "dompurify": "^3.0.1", + "fast-json-patch": "~3.1.1", "highlight.js": "^11.11.1", - "markdown-it": "^14.1.0", - "jsonrepair": "3.13.0" + "jsonrepair": "3.13.0", + "markdown-it": "^14.1.0" }, "devDependencies": { "@opentiny/tiny-engine-vite-plugin-meta-comments": "workspace:*", diff --git a/packages/plugins/robot/src/BuildLoadingRenderer.vue b/packages/plugins/robot/src/BuildLoadingRenderer.vue deleted file mode 100644 index 7643b888de..0000000000 --- a/packages/plugins/robot/src/BuildLoadingRenderer.vue +++ /dev/null @@ -1,58 +0,0 @@ - - - - - diff --git a/packages/plugins/robot/src/Main.vue b/packages/plugins/robot/src/Main.vue index 05cb6c1e06..114c66121a 100644 --- a/packages/plugins/robot/src/Main.vue +++ b/packages/plugins/robot/src/Main.vue @@ -9,1004 +9,244 @@
- -
-
- - - -
- + + + - - -
- - - diff --git a/packages/plugins/robot/src/components/chat/RobotChat.vue b/packages/plugins/robot/src/components/chat/RobotChat.vue new file mode 100644 index 0000000000..bcfb867372 --- /dev/null +++ b/packages/plugins/robot/src/components/chat/RobotChat.vue @@ -0,0 +1,593 @@ + + + + + diff --git a/packages/plugins/robot/src/mcp/McpServer.vue b/packages/plugins/robot/src/components/footer-extension/McpServer.vue similarity index 50% rename from packages/plugins/robot/src/mcp/McpServer.vue rename to packages/plugins/robot/src/components/footer-extension/McpServer.vue index 488e439b7d..96781fa6c4 100644 --- a/packages/plugins/robot/src/mcp/McpServer.vue +++ b/packages/plugins/robot/src/components/footer-extension/McpServer.vue @@ -1,30 +1,35 @@ diff --git a/packages/plugins/robot/src/icon-prompt/page-icon.vue b/packages/plugins/robot/src/components/icons/page-icon.vue similarity index 94% rename from packages/plugins/robot/src/icon-prompt/page-icon.vue rename to packages/plugins/robot/src/components/icons/page-icon.vue index ae82278def..bd54f58f23 100644 --- a/packages/plugins/robot/src/icon-prompt/page-icon.vue +++ b/packages/plugins/robot/src/components/icons/page-icon.vue @@ -7,8 +7,6 @@ xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" > - Created with Pixso. - - - diff --git a/packages/plugins/robot/src/icon-prompt/study-icon.vue b/packages/plugins/robot/src/components/icons/study-icon.vue similarity index 93% rename from packages/plugins/robot/src/icon-prompt/study-icon.vue rename to packages/plugins/robot/src/components/icons/study-icon.vue index b851005195..40a0d1245a 100644 --- a/packages/plugins/robot/src/icon-prompt/study-icon.vue +++ b/packages/plugins/robot/src/components/icons/study-icon.vue @@ -7,8 +7,6 @@ xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" > - Created with Pixso. - - - diff --git a/packages/plugins/robot/src/components/renderers/AgentRenderer.vue b/packages/plugins/robot/src/components/renderers/AgentRenderer.vue new file mode 100644 index 0000000000..6d10bd60ad --- /dev/null +++ b/packages/plugins/robot/src/components/renderers/AgentRenderer.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/packages/plugins/robot/src/components/renderers/ImgRenderer.vue b/packages/plugins/robot/src/components/renderers/ImgRenderer.vue new file mode 100644 index 0000000000..76de912b25 --- /dev/null +++ b/packages/plugins/robot/src/components/renderers/ImgRenderer.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/packages/plugins/robot/src/components/renderers/LoadingRenderer.vue b/packages/plugins/robot/src/components/renderers/LoadingRenderer.vue new file mode 100644 index 0000000000..86f4891786 --- /dev/null +++ b/packages/plugins/robot/src/components/renderers/LoadingRenderer.vue @@ -0,0 +1,3 @@ + diff --git a/packages/plugins/robot/src/mcp/MarkdownRenderer.vue b/packages/plugins/robot/src/components/renderers/MarkdownRenderer.vue similarity index 100% rename from packages/plugins/robot/src/mcp/MarkdownRenderer.vue rename to packages/plugins/robot/src/components/renderers/MarkdownRenderer.vue diff --git a/packages/plugins/robot/src/composables/useAgent.ts b/packages/plugins/robot/src/composables/useAgent.ts new file mode 100644 index 0000000000..c111de1236 --- /dev/null +++ b/packages/plugins/robot/src/composables/useAgent.ts @@ -0,0 +1,225 @@ +import { jsonrepair } from 'jsonrepair' +import * as jsonpatch from 'fast-json-patch' +import { utils } from '@opentiny/tiny-engine-utils' +import { getMetaApi, META_SERVICE, useCanvas, useHistory } from '@opentiny/tiny-engine-meta-register' +import SvgICons from '@opentiny/vue-icon' +import { useThrottleFn } from '@vueuse/core' +import useModelConfig from './useConfig' +import { serializeError } from '../utils' + +const { deepClone } = utils + +const logger = console + +const setSchema = (schema: object) => { + const { importSchema, setSaved } = useCanvas() + importSchema(schema) + setSaved(false) +} + +const fixIconComponent = (data: unknown) => { + if (data?.componentName === 'Icon' && data.props?.name && !SvgICons[data.props.name as keyof typeof SvgICons]) { + data.props.name = 'IconWarning' + logger.log('autofix icon to warning:', data) + } +} + +const isPlainObject = (value: unknown) => + typeof value === 'object' && value !== null && Object.prototype.toString.call(value) === '[object Object]' + +const fixComponentName = (data: object) => { + if (isPlainObject(data) && !data.componentName && !data.op && !data.path) { + data.componentName = 'div' + logger.log('autofix component to div:', data) + } +} + +const fixMethods = (methods: Record) => { + if (methods && Object.keys(methods).length) { + Object.entries(methods).forEach(([methodName, methodValue]: [string, any]) => { + if ( + typeof methodValue !== 'object' || + methodValue?.type !== 'JSFunction' || + !methodValue?.value.startsWith('function') + ) { + methods[methodName] = { + type: 'JSFunction', + value: 'function ' + methodName + '() {\n console.log("' + methodName + '");\n}' + } + logger.log('autofix method to empty function:', methodName, methods[methodName]) + } + }) + } +} + +const schemaAutoFix = (data: object | object[]) => { + if (!data) return + if (Array.isArray(data)) data.forEach((item) => schemaAutoFix(item)) + fixIconComponent(data) + fixComponentName(data) + if (data.children && Array.isArray(data.children)) { + data.children.forEach((child: any) => schemaAutoFix(child)) + } +} + +const isValidOperation = (operation: object) => { + const allowedOps = ['add', 'remove', 'replace', 'move', 'copy', 'test', '_get'] + + if (typeof operation !== 'object' || operation === null) { + return false + } + // 检查操作类型是否有效 + if (!operation.op || !allowedOps.includes(operation.op)) { + return false + } + // 检查path字段是否存在且为字符串 + if (!operation.path || typeof operation.path !== 'string') { + return false + } + // 根据操作类型检查其他必需字段 + switch (operation.op) { + case 'add': + case 'replace': + case 'test': + if (!('value' in operation)) { + return false + } + break + case 'move': + case 'copy': + if (!operation.from || typeof operation.from !== 'string') { + return false + } + break + } + + return true +} + +const isValidFastJsonPatch = (patch) => { + if (Array.isArray(patch)) { + return patch.every(isValidOperation) + } else if (typeof patch === 'object' && patch !== null) { + return isValidOperation(patch) + } + return false +} + +const jsonPatchAutoFix = (jsonPatches: any[], isFinial: boolean) => { + // 流式渲染过程中,画布只渲染完整的字段或流式的children字段,避免不完整的methods/states/css等字段导致解析报错 + const childrenFilter = (patch, index, arr) => + isFinial || index < arr.length - 1 || (index === arr.length - 1 && patch.path?.startsWith('/children')) + const validJsonPatches = jsonPatches.filter(childrenFilter).filter(isValidFastJsonPatch) + + return validJsonPatches +} + +export const getJsonObjectString = (streamContent: string) => { + const regex = /```(json|schema)?([\s\S]*?)```/ + const match = streamContent.match(regex) + return (match && match[2]) || streamContent +} + +export const isValidJsonPatchObjectString = (streamContent: string) => { + const jsonString = getJsonObjectString(streamContent) + try { + const data = JSON.parse(jsonString) + if (!isValidFastJsonPatch(data)) { + return { + isError: true, + error: + 'format error: not a valid json patch format(strictly `RFC 6902` compliant JSON Patch array. Format example: `[{ "op": "add", "path": "/children/0", "value": { ... } }, {"op":"add","path":"/methods/handleBtnClick","value": { ... }}, { "op": "replace", "path": "/css", "value": "..." }]`), please check and fix the json patch format.' + } + } + return { isError: false, data } + } catch (error) { + return { isError: true, error: serializeError(error) } + } +} + +const _updatePageSchema = (streamContent: string, currentPageSchema: object, isFinial: boolean = false) => { + const { robotSettingState, CHAT_MODE } = useModelConfig() + if (robotSettingState.chatMode !== CHAT_MODE.Agent) { + return + } + + // 解析流式返回的schema patch + let content = getJsonObjectString(streamContent) + let jsonPatches = [] + try { + if (!isFinial) { + content = jsonrepair(content) + } + jsonPatches = JSON.parse(content) + } catch (error) { + if (isFinial) { + logger.error('parse json patch error:', error) + } + return { isError: true, error } + } + + // 过滤有效的json patch + if (!isFinial && !isValidFastJsonPatch(jsonPatches)) { + return { isError: true, error: 'format error: not a valid json patch.' } + } + const validJsonPatches = jsonPatchAutoFix(jsonPatches, isFinial) + + // 生成新schema + const originSchema = deepClone(currentPageSchema) + const newSchema = validJsonPatches.reduce((acc: object, patch: any) => { + try { + return jsonpatch.applyPatch(acc, [patch], false, false).newDocument + } catch (error) { + if (isFinial) { + logger.error('apply patch error:', error, patch) + } + return acc + } + }, originSchema) + + // schema纠错 + fixMethods(newSchema.methods) + schemaAutoFix(newSchema.children) + + // 更新Schema + setSchema(newSchema) + if (isFinial) { + useHistory().addHistory() + } + + return { schema: newSchema, isError: false } +} + +export const updatePageSchema = useThrottleFn(_updatePageSchema, 200, true) + +export const search = async (content: string) => { + let result = '' + const MAX_SEARCH_LENGTH = 8000 + try { + const res = await getMetaApi(META_SERVICE.Http).post('/app-center/api/ai/search', { content }) + + res.forEach((item: { content: string }) => { + if (result.length + item.content.length > MAX_SEARCH_LENGTH) { + return + } + result += item.content + }) + } catch (error) { + // error + } + return result +} + +export const fetchAssets = async () => { + try { + const res = (await getMetaApi(META_SERVICE.Http).get('/material-center/api/resource/find/1')) || [] + return res + .filter((item) => item.description) + .map((item) => ({ + url: item.resourceUrl, + describe: item.description + })) + } catch (error) { + return [] + } +} diff --git a/packages/plugins/robot/src/composables/useChat.ts b/packages/plugins/robot/src/composables/useChat.ts new file mode 100644 index 0000000000..9b260596cb --- /dev/null +++ b/packages/plugins/robot/src/composables/useChat.ts @@ -0,0 +1,411 @@ +import { toRaw } from 'vue' +import { getMetaApi, getOptions, META_SERVICE, useCanvas } from '@opentiny/tiny-engine-meta-register' +import type { BubbleContentItem } from '@opentiny/tiny-robot' +import { + STATUS, + useConversation, + type AIModelConfig, + type ChatCompletionStreamResponse, + type ChatCompletionStreamResponseChoice, + type ChatMessage, + type UseMessageOptions +} from '@opentiny/tiny-robot-kit' +import { utils } from '@opentiny/tiny-engine-utils' +import { formatMessages, serializeError, mergeStringFields } from '../utils' +import type { LLMMessage, ResponseToolCall, RobotMessage } from '../types' +import { createClient } from '../client' +import useMcpServer from './useMcp' +import { updatePageSchema, fetchAssets, search, isValidJsonPatchObjectString } from './useAgent' +import useModelConfig from './useConfig' +import { getAgentSystemPrompt, getJsonFixPrompt } from '../prompts' +import meta from '../../meta' + +const { deepClone } = utils + +type Message = ChatMessage & { + renderContent: BubbleContentItem[] + tool_calls: ResponseToolCall[] +} + +const { robotSettingState, CHAT_MODE, saveRobotSettingState } = useModelConfig() + +const getApiUrl = () => { + return robotSettingState.chatMode === CHAT_MODE.Agent ? '/app-center/api/ai/chat' : '/app-center/api/chat/completions' +} + +const config: Omit & { + axiosClient?: unknown + httpClientType?: 'axios' | 'fetch' +} = { + apiKey: robotSettingState.selectedModel.apiKey || '', + apiUrl: getApiUrl(), + defaultModel: robotSettingState.selectedModel.model || 'deepseek-v3', + axiosClient: () => getMetaApi(META_SERVICE.Http)?.getHttp(), + httpClientType: 'axios' +} + +const addSystemPrompt = (messages: LLMMessage[], prompt: string = '') => { + if (!messages.length || messages[0].role !== 'system') { + messages.unshift({ role: 'system', content: prompt }) + } else if (messages[0].role === 'system' && messages[0].content !== prompt) { + messages[0].content = prompt + } +} + +const beforeRequest = async (requestParams: any) => { + const { CHAT_MODE, robotSettingState, getModelCapabilities } = useModelConfig() + const pageSchema = deepClone(useCanvas().pageState.pageSchema) + const isAgentMode = robotSettingState.chatMode === CHAT_MODE.Agent + const tools = await useMcpServer().getLLMTools() + const modelCapabilities = getModelCapabilities( + robotSettingState.selectedModel.baseUrl, + robotSettingState.selectedModel.model + ) + if (!requestParams.tools && tools?.length && !isAgentMode && modelCapabilities?.toolCalling !== false) { + Object.assign(requestParams, { tools }) + } + if (isAgentMode) { + requestParams.apiKey = robotSettingState.selectedModel.apiKey + let referenceContext = '' + let imageAssets = [] + if (requestParams.messages[0]?.role !== 'system') { + if (getOptions(meta.id)?.enableRagContext) { + referenceContext = await search(requestParams.messages?.at(-1)?.content) + } + if (getOptions(meta.id)?.enableResourceContext !== false) { + imageAssets = await fetchAssets() + } + addSystemPrompt(requestParams.messages, getAgentSystemPrompt(pageSchema, referenceContext, imageAssets)) + } + if (!robotSettingState.enableThinking) { + Object.assign(requestParams, { response_format: { type: 'json_object' } }) + } + } + requestParams.baseUrl = robotSettingState.selectedModel.baseUrl + requestParams.model = robotSettingState.selectedModel.model + if (modelCapabilities?.reasoning?.extraBody) { + Object.assign( + requestParams, + robotSettingState.enableThinking + ? modelCapabilities.reasoning.extraBody.enable + : modelCapabilities.reasoning.extraBody.disable + ) + } + if (config.apiKey !== robotSettingState.selectedModel.apiKey) { + provider?.updateConfig({ apiKey: robotSettingState.selectedModel.apiKey }) // eslint-disable-line + config.apiKey = robotSettingState.selectedModel.apiKey + } + return requestParams +} + +const { client, provider } = createClient({ config, beforeRequest }) + +const updateLLMConfig = (newConfig: Omit) => { + provider?.updateConfig(newConfig) + Object.assign(config, newConfig) +} + +const removeLoading = (messages: ChatMessage[], name?: string) => { + const renderContent = messages.at(-1)?.renderContent + if (!renderContent || !renderContent.length) return + const index = renderContent.findLastIndex((item) => item.type === 'loading' && (name ? item.content === name : true)) + if (index !== -1) { + renderContent?.splice(index, 1) + } +} + +let chatStatus = STATUS.INIT +let pageSchema = null +let chatAbortController: AbortController | null = null + +const events: UseMessageOptions['events'] = { + onReceiveData: (data: ChatCompletionStreamResponse, messages, preventDefault) => { + preventDefault() + const choice = data.choices?.[0] + if (!choice) { + return + } + if (chatStatus !== STATUS.STREAMING) { + chatStatus = STATUS.STREAMING + pageSchema = deepClone(useCanvas().pageState.pageSchema) + } + const lastMessage = messages.value.at(-1) + if (choice.delta.reasoning_content || choice.delta.content || choice.delta.tool_calls?.length) { + removeLoading(messages.value) + } + handleDeltaReasoning(choice, lastMessage) // eslint-disable-line + handleDeltaContent(choice, lastMessage) // eslint-disable-line + handleDeltaToolCalls(choice, lastMessage) // eslint-disable-line + + updatePageSchema(lastMessage.content, pageSchema) + }, + async onFinish(finishReason, { messages, messageState }, preventDefault) { + preventDefault() + const lastMessage = messages.value.at(-1) + if (finishReason === 'tool_calls') { + handleToolCall(lastMessage.tool_calls, messages.value) // eslint-disable-line + } else if (finishReason !== 'abort' && messageState.status !== STATUS.ABORTED) { + if (robotSettingState.chatMode === CHAT_MODE.Agent) { + const jsonValidResult = isValidJsonPatchObjectString(lastMessage.content) + if (jsonValidResult.isError) { + chatAbortController = new AbortController() + try { + const beforeRequest = (requestParams: any) => { + const { robotSettingState, getModelCapabilities } = useModelConfig() + const modelCapabilities = getModelCapabilities( + robotSettingState.selectedModel.baseUrl, + robotSettingState.selectedModel.model + ) + if (modelCapabilities?.reasoning?.extraBody?.disable) { + Object.assign(requestParams, modelCapabilities.reasoning.extraBody.disable) + } + Object.assign(requestParams, { + response_format: { type: 'json_object' }, + model: robotSettingState.selectedModel.model, + baseUrl: robotSettingState.selectedModel.baseUrl + }) + return requestParams + } + updateLLMConfig({ apiUrl: '/app-center/api/chat/completions' }) + messages.value.at(-1).renderContent.at(-1).status = 'fix' + const fixedResponse = await client.chat({ + messages: [{ role: 'user', content: getJsonFixPrompt(lastMessage.content, jsonValidResult.error) }], + options: { signal: chatAbortController?.signal, beforeRequest: beforeRequest as any } + }) + if (!isValidJsonPatchObjectString(fixedResponse.choices[0].message.content).isError) { + lastMessage.originContent = lastMessage.content + lastMessage.content = fixedResponse.choices[0].message.content + } + } catch (error) { + console.error('json fix failed', error) // eslint-disable-line + } + updateLLMConfig({ apiUrl: getApiUrl() }) + } + + const result = await updatePageSchema(lastMessage.content, pageSchema, true) + if (result.schema) { + messages.value.at(-1).renderContent.at(-1).status = 'success' + messages.value.at(-1).renderContent.at(-1).schema = result.schema + } else { + messages.value.at(-1).renderContent.at(-1).status = 'failed' + } + } + messageState.status = STATUS.FINISHED + } + chatStatus = messageState.status + pageSchema = null + } +} + +const { messageManager, state: conversationState, createConversation, ...rest } = useConversation({ client, events }) + +const getMessageManager = () => messageManager + +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 handleDeltaContent = (choice: ChatCompletionStreamResponseChoice, lastMessage: Message) => { + 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 !== 'markdown') { + lastMessage.renderContent.push({ type: 'markdown', content: '' }) + lastMessage.content = '' + } + lastMessage.renderContent.at(-1).content += choice.delta.content + lastMessage.content += choice.delta.content + } +} + +const handleDeltaToolCalls = (choice: ChatCompletionStreamResponseChoice, lastMessage: Message) => { + const toolCallChunks = choice.delta.tool_calls as (ResponseToolCall & { index: number })[] + if (Array.isArray(toolCallChunks) && toolCallChunks.length) { + if (!lastMessage.tool_calls) { + lastMessage.tool_calls = [] + } + for (const chunk of toolCallChunks) { + const { index, ...chunkWithoutIndex } = chunk + if (lastMessage.tool_calls[index]) { + mergeStringFields(lastMessage.tool_calls[index], chunkWithoutIndex) + } else { + lastMessage.tool_calls[index] = chunkWithoutIndex + } + } + } +} + +const parseArgs = (args: string) => { + try { + return JSON.parse(args) + } catch (error) { + return args + } +} + +const handleToolCall = async ( + tool_calls: ResponseToolCall[], + messages: ChatMessage[], + contextMessages?: RobotMessage[] +) => { + const hasToolCall = tool_calls?.length > 0 + if (!hasToolCall) { + return + } + + chatAbortController = new AbortController() + + const currentMessage = messages.at(-1) + const historyMessages = contextMessages?.length ? contextMessages : messages.slice(0, -1) + const toolMessages: LLMMessage[] = formatMessages([...historyMessages, toRaw(currentMessage)]) + for (const tool of tool_calls) { + const { name, arguments: args } = tool.function + const parsedArgs = parseArgs(args) + const currentToolMessage = { + type: 'tool', + name, + status: 'running', + content: { + params: parsedArgs + }, + formatPretty: true + } + currentMessage.renderContent.push(currentToolMessage) + let toolCallResult: string + let toolCallStatus: 'success' | 'failed' + try { + const resp = await useMcpServer().callTool(name, parsedArgs) + toolCallStatus = 'success' + toolCallResult = resp.content + } catch (error) { + toolCallStatus = 'failed' + toolCallResult = serializeError(error) + } + toolMessages.push({ + content: JSON.stringify(toolCallResult), + role: 'tool', + tool_call_id: tool.id + }) + currentMessage.renderContent.at(-1)!.status = toolCallStatus + currentMessage.renderContent.at(-1)!.content = { + params: parsedArgs, + result: toolCallResult + } + + if (chatAbortController?.signal?.aborted) { + return + } + } + delete currentMessage.tool_calls + currentMessage.renderContent.push({ type: 'loading', content: '' }) + + await client.chatStream( + { messages: toolMessages, options: { signal: chatAbortController?.signal } }, + { + onData: (data) => { + if ( + data.choices[0].delta.reasoning_content || + data.choices[0].delta.content || + data.choices[0].delta.tool_calls?.length + ) { + removeLoading(messages) + } + if (data.choices[0].delta.reasoning_content) { + handleDeltaReasoning(data.choices[0], currentMessage) + } + if (data.choices[0].delta.content) { + handleDeltaContent(data.choices[0], currentMessage) + } + if (data.choices[0].delta.tool_calls?.length) { + handleDeltaToolCalls(data.choices[0], currentMessage) + } + }, + onError: (error) => { + removeLoading(messages) + messages.at(-1)!.renderContent.push({ type: 'text', content: serializeError(error) }) + // eslint-disable-next-line no-console + console.error(error) + getMessageManager().messageState.status = STATUS.ERROR + }, + onDone: async () => { + removeLoading(messages) + const toolCalls = messages.at(-1)!.tool_calls + if (toolCalls?.length) { + await handleToolCall(toolCalls, messages, toolMessages) + } else { + getMessageManager().messageState.status = STATUS.FINISHED + } + } + } + ) +} + +const changeChatMode = (chatMode: string) => { + // 空会话更新metadata + const usedConversationId = conversationState.currentId + const newConversationId = createConversation('新会话', { chatMode }) + if (usedConversationId === newConversationId) { + rest.updateMetadata(newConversationId, { chatMode }) + rest.saveConversations() + } + + robotSettingState.chatMode = chatMode + saveRobotSettingState({ chatMode }) + updateLLMConfig({ apiUrl: getApiUrl() }) +} + +export default function () { + return { + updateLLMConfig, + conversationState, + ...messageManager, + changeChatMode, + abortRequest: () => { + chatAbortController?.abort() + messageManager.abortRequest() + messageManager.messageState.status = STATUS.ABORTED + removeLoading(messageManager.messages.value) + }, + ...rest, + switchConversation: (conversationId: string) => { + const conversation = conversationState.conversations.find((conversation) => conversation.id === conversationId) + if (!conversation) return + + rest.switchConversation(conversationId) + // 切换会话后跟随切换对话模式 + if (conversation.metadata?.chatMode) { + robotSettingState.chatMode = robotSettingState.chatMode as string + } else { + robotSettingState.chatMode = CHAT_MODE.Agent + rest.updateMetadata(conversationId, { chatMode: CHAT_MODE.Agent }) + rest.saveConversations() + } + if (robotSettingState.chatMode === CHAT_MODE.Agent) { + messageManager.messages.value.at(-1)?.renderContent?.forEach((item) => { + if (item.type === 'loading') { + item.status = 'failed' + } + }) + } else { + removeLoading(messageManager.messages.value) + } + }, + createConversation: (title?: string) => { + createConversation(title, { chatMode: robotSettingState.chatMode }) + }, + removeLoading + } +} diff --git a/packages/plugins/robot/src/composables/useConfig.ts b/packages/plugins/robot/src/composables/useConfig.ts new file mode 100644 index 0000000000..51f89c965f --- /dev/null +++ b/packages/plugins/robot/src/composables/useConfig.ts @@ -0,0 +1,155 @@ +/** + * Copyright (c) 2023 - present TinyEngine Authors. + * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. + * + * Use of this source code is governed by an MIT-style license. + * + * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, + * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR + * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. + * + */ + +/* metaService: engine.plugins.robot.useRobot */ +import { reactive } from 'vue' +import { getOptions } from '@opentiny/tiny-engine-meta-register' +import meta from '../../meta' +import { DEFAULT_LLM_MODELS } from '../constants' + +const EXISTING_MODELS = 'existingModels' +const CUSTOMIZE = 'customize' +const CHAT_MODE = { Agent: 'agent', Chat: 'chat' } + +/** + * 合并 AI 模型配置 + * 支持: + * 1. 通过 _remove: true 删除整个 provider(基于 baseUrl 匹配) + * 2. 通过在 model 中设置 _remove: true 删除特定 model + * 3. 相同 baseUrl 的 provider 会合并其 models + * 4. 相同 value 的 model 会被自定义配置覆盖 + * @param defaults 默认配置 + * @param customs 自定义配置 + * @returns 合并后的配置 + */ +const mergeAIModelOptions = (defaults: any[], customs: any[]): any[] => { + // 深拷贝默认配置作为基础 + const result = JSON.parse(JSON.stringify(defaults)) + + customs.forEach((customProvider) => { + // 如果标记删除整个 provider(基于 baseUrl 匹配) + if (customProvider._remove) { + const index = result.findIndex((p: any) => p.baseUrl === customProvider.baseUrl) + if (index !== -1) { + result.splice(index, 1) + } + return + } + + // 查找相同 baseUrl (value 字段) 的 provider + const existingProviderIndex = result.findIndex((p: any) => p.baseUrl === customProvider.baseUrl) + + if (existingProviderIndex !== -1) { + // 找到相同 baseUrl 的 provider,合并 models + const existingProvider = result[existingProviderIndex] + + customProvider.models?.forEach((customModel: any) => { + if (customModel._remove) { + // 移除指定的 model + const modelIndex = existingProvider.models.findIndex((m: any) => m.name === customModel.name) + if (modelIndex !== -1) { + existingProvider.models.splice(modelIndex, 1) + } + } else { + // 查找是否存在相同 value 的 model + const existingModelIndex = existingProvider.models.findIndex((m: any) => m.name === customModel.name) + if (existingModelIndex !== -1) { + // 替换已有 model(覆盖) + const { _remove, ...modelWithoutRemove } = customModel + existingProvider.models[existingModelIndex] = modelWithoutRemove + } else { + // 添加新 model + const { _remove, ...modelWithoutRemove } = customModel + existingProvider.models.push(modelWithoutRemove) + } + } + }) + + // 更新 provider 的其他属性(如果提供了) + if (customProvider.label) existingProvider.label = customProvider.label + if (customProvider.provider) existingProvider.provider = customProvider.provider + } else { + // 添加新的 provider + const { _remove, ...providerWithoutRemove } = customProvider + providerWithoutRemove.models = (providerWithoutRemove.models || []) + .filter((m: any) => !m._remove) + .map((m: any) => { + const { _remove, ...modelWithoutRemove } = m + return modelWithoutRemove + }) + result.push(providerWithoutRemove) + } + }) + + return result +} + +const getAIModelOptions = () => { + const customAIModels = getOptions(meta.id)?.customCompatibleAIModels || [] + if (!customAIModels.length) { + return DEFAULT_LLM_MODELS + } + return mergeAIModelOptions(DEFAULT_LLM_MODELS, customAIModels) +} + +const getModelCapabilities = (baseUrl: string, model: string) => { + return getAIModelOptions() + .find((option: any) => option.baseUrl === baseUrl) + ?.models.find((item: any) => item.name === model)?.capabilities +} + +const SETTING_STORAGE_KEY = 'tiny-engine-robot-settings' + +const loadRobotSettingState = () => { + const items = localStorage.getItem(SETTING_STORAGE_KEY) || '{}' + try { + return JSON.parse(items) + } catch (error) { + return items + } +} + +const saveRobotSettingState = (state: object) => { + const currentState = loadRobotSettingState() || {} + const newState = { ...currentState, ...state } + localStorage.setItem(SETTING_STORAGE_KEY, JSON.stringify(newState)) +} + +const { activeName, existModel, customizeModel, chatMode, enableThinking } = loadRobotSettingState() || {} + +const storageSettingState = (activeName === EXISTING_MODELS ? existModel : customizeModel) || {} + +const robotSettingState = reactive({ + selectedModel: { + label: storageSettingState.label || getAIModelOptions()[0].label, + activeName: activeName || EXISTING_MODELS, + baseUrl: storageSettingState.baseUrl || getAIModelOptions()[0].value, + model: storageSettingState.model || getAIModelOptions()[0].models[0].value, + completeModel: storageSettingState.completeModel || getAIModelOptions()[0].models[0].value || '', + apiKey: storageSettingState.apiKey || '' + }, + chatMode: chatMode || CHAT_MODE.Agent, + enableThinking: enableThinking || false +}) + +export default () => { + return { + saveRobotSettingState, + loadRobotSettingState, + EXISTING_MODELS, + CUSTOMIZE, + CHAT_MODE, + getAIModelOptions, + getModelCapabilities, + robotSettingState + } +} diff --git a/packages/plugins/robot/src/mcp/useMcp.ts b/packages/plugins/robot/src/composables/useMcp.ts similarity index 83% rename from packages/plugins/robot/src/mcp/useMcp.ts rename to packages/plugins/robot/src/composables/useMcp.ts index 26963e1cd9..adfa9af6e8 100644 --- a/packages/plugins/robot/src/mcp/useMcp.ts +++ b/packages/plugins/robot/src/composables/useMcp.ts @@ -1,7 +1,8 @@ import { computed, ref } from 'vue' import type { PluginInfo, PluginTool } from '@opentiny/tiny-robot' import { getMetaApi, META_SERVICE } from '@opentiny/tiny-engine-meta-register' -import type { McpListToolsResponse, McpTool, RequestTool } from './types' +import type { McpTool } from '../types/mcp-types' +import type { RequestTool } from '../types/types' const ENGINE_MCP_SERVER: PluginInfo = { id: 'tiny-engine-mcp-server', @@ -11,8 +12,6 @@ const ENGINE_MCP_SERVER: PluginInfo = { added: true } -const mcpServers = ref([ENGINE_MCP_SERVER]) - const inUseMcpServers = ref([{ ...ENGINE_MCP_SERVER, enabled: true, expanded: true, tools: [] }]) const updateServerTools = (serverId: string, tools: PluginTool[]) => { @@ -68,12 +67,6 @@ const updateEngineServer = (engineServer: PluginInfo, enabled: boolean) => { }) } -// TODO: 连接MCP Server -const connectMcpServer = (_server: PluginInfo) => {} - -// TODO: 断开连接 -const disconnectMcpServer = (_server: PluginInfo) => {} - const updateMcpServerStatus = async (server: PluginInfo, added: boolean) => { // 市场添加状态修改 server.added = added @@ -91,15 +84,11 @@ const updateMcpServerStatus = async (server: PluginInfo, added: boolean) => { await updateEngineTools() updateEngineServer(newServer, added) } - // TODO: 连接MCP Server - connectMcpServer(newServer) } else { const index = inUseMcpServers.value.findIndex((p) => p.id === server.id) if (index > -1) { updateEngineServer(inUseMcpServers.value[index], added) inUseMcpServers.value.splice(index, 1) - // TODO: 断开连接 - disconnectMcpServer(server) } } } @@ -110,9 +99,6 @@ const updateMcpServerToolStatus = (currentServer: PluginInfo, toolId: string, en tool.enabled = enabled if (currentServer.id === ENGINE_MCP_SERVER.id) { updateEngineServerToolStatus(toolId, enabled) - } else { - // TODO: 更新MCP Server的Tool状态 - // 获取 tool 实例调用 enableTool 或 disableTool } } } @@ -121,20 +107,24 @@ const refreshMcpServerTools = () => { updateEngineTools() } -const listTools = async (): Promise => - getMetaApi(META_SERVICE.McpService)?.getMcpClient()?.listTools() +let llmTools: RequestTool[] | null = null + +const listTools = async (): Promise => { + const mcpTools = await getMetaApi(META_SERVICE.McpService)?.getMcpClient()?.listTools() + return mcpTools +} const callTool = async (toolId: string, args: Record) => getMetaApi(META_SERVICE.McpService)?.getMcpClient()?.callTool({ name: toolId, arguments: args }) || {} const getLLMTools = async () => { - const mcpTools = await listTools() - return convertMCPToOpenAITools(mcpTools?.tools || []) + const mcpTools = await getMetaApi(META_SERVICE.McpService)?.getMcpClient()?.listTools() + llmTools = convertMCPToOpenAITools(mcpTools?.tools || []) + return llmTools } export default function useMcpServer() { return { - mcpServers, inUseMcpServers, refreshMcpServerTools, updateMcpServerStatus, diff --git a/packages/plugins/robot/src/constants/index.ts b/packages/plugins/robot/src/constants/index.ts new file mode 100644 index 0000000000..504e83352d --- /dev/null +++ b/packages/plugins/robot/src/constants/index.ts @@ -0,0 +1 @@ +export * from './model-config' diff --git a/packages/plugins/robot/src/constants/model-config.ts b/packages/plugins/robot/src/constants/model-config.ts new file mode 100644 index 0000000000..84c970d6fc --- /dev/null +++ b/packages/plugins/robot/src/constants/model-config.ts @@ -0,0 +1,92 @@ +const reasoningExtraBody = { + extraBody: { + enable: { + enable_thinking: true, + thinking_budget: 1000 + }, + disable: null + } +} + +export const DEFAULT_LLM_MODELS = [ + { + provider: 'bailian', + label: '阿里云百炼', + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + allowEmptyApiKey: false, + models: [ + // Agent/chat + { + label: 'Qwen 通用模型(Plus)', + name: 'qwen-plus', + capabilities: { + toolCalling: true, + reasoning: reasoningExtraBody + } + }, + // 备注:千问多模态模型不支持工具调用; + { + label: 'Qwen VL视觉理解模型(PLUS)', + name: 'qwen3-vl-plus', + capabilities: { + vision: true, + reasoning: reasoningExtraBody + } + }, + { + label: 'Qwen Coder编程模型(PLUS)', + name: 'qwen3-coder-plus', + capabilities: { + toolCalling: true, + reasoning: reasoningExtraBody + } + }, + { + label: 'DeepSeek(v3.2)', + name: 'deepseek-v3.2-exp', + capabilities: { + toolCalling: true, + reasoning: reasoningExtraBody + } + }, + // 小参数模型 + { + label: 'Qwen 通用模型(Flash)', + name: 'qwen-flash', + capabilities: { + compact: true + } + }, + { + label: 'Qwen Coder编程模型(Flash)', + name: 'qwen3-coder-flash', + capabilities: { + compact: true + } + }, + { label: 'Qwen3(14b)', name: 'qwen3-14b', capabilities: { compact: true } }, + { label: 'Qwen3(8b)', name: 'qwen3-8b', capabilities: { compact: true } } + ] + }, + { + provider: 'deepseek', + label: 'DeepSeek', + baseUrl: 'https://api.deepseek.com/v1', + allowEmptyApiKey: false, + models: [ + { + label: 'DeepSeek', + name: 'deepseek-chat', + capabilities: { + toolCalling: true, + reasoning: { + extraBody: { + enable: { model: 'deepseek-reasoner' }, + disable: { model: 'deepseek-chat' } + } + } + } + } + ] + } +] diff --git a/packages/plugins/robot/src/js/prompts.ts b/packages/plugins/robot/src/js/prompts.ts deleted file mode 100644 index 23f194d2ef..0000000000 --- a/packages/plugins/robot/src/js/prompts.ts +++ /dev/null @@ -1,138 +0,0 @@ -export const PROMPTS = ` -# 静默JSON生成指令 -你是一个严格的JSON Patch生成器,必须且只能输出如下格式的内容: - -\`\`\`json -/** 严格按照RFC 6902和IPageSchema规范的JSON Patch数组 */ -[ - { - "op": "add", - "path": "/children/-", // 根据当前schema去生成路径,新生成的模块从尾部添加。 - "value": { - "componentName": "CanvasFlexBox", - "id": "/* 随机生成8位数字符串(字母+数字) */", - "props": { - "className": "header-style", - "justifyContent": "space-between", - "alignItems": "center" - }, - "children": [ - { - "componentName": "img", - "id": "/* 随机生成8位数字符串(字母+数字) */", - "props": { - "src": "https://res-static.hc-cdn.cn/cloudbu-site/intl/zh-cn/yunying/header-new/logo.png", - "alt": "华为云Logo" - } - } - ] - } - } -] -\`\`\` - -## 目标 -根据用户提供的图片/需求,生成value为IPageSchema规范数据的JSON Patch数据,在低代码中能够渲染出华为云官网的页面 - -## 绝对规则 -1. 禁止输出任何非JSON内容,包括: - - 解释性文字 - - 提示语(如"以下是...") - - 未完成的标记(如...) -2. 必须包含完整的JSON结构: - - 始终以\`\`\`json开头和结尾 - - 确保数组闭合(所有括号匹配) - - 包含所有必需的字段(componentName/id等) - - 仅使用双引号,禁止单引号(如错误示例中的'autoplay') -- 所有key必须加双引号(如"op"而非op) -- 结尾不允许有多余逗号(如"children": [ {...}, ] ❌) -- 布尔值必须小写(true/false,非'false'字符串) -- 不要在json中添加注释,比如 "" 、 ""、 “// 添加顶部导航栏 (假设为一个容器)”、“ {/*首页大标题*/}”等 -- 不要有多余的空行和空格 -3. 错误处理: - - 如果无法生成完整数据,返回空数组:\`\`\`json []\`\`\` - - 不允许部分输出或占位注释 -4. 其中每个value值必须精确遵循IPageSchema规范 -5. 严格按照用户提供的图片在每个组件的props.style字段生成样式(值为字符串格式,与行内样式格式相同) -6. 保留上一次生成的模块,不要把上一次生成的内容删除或者完全覆盖掉 - -**错误修复示范**: - - ❌ 'autoplay': 'false' → ✅ "autoplay": false - - ❌ 'id': 'headerDiv' → ✅ "id": "headerDiv" - - ❌ 'indicator-position ' → ✅ "indicatorPosition"(移除空格和连字符) - -## 修正模板(对照错误示例) -错误示例: -\`\`\`json -{ - "componentName": "button", - "props": { - classNames: ["primary-btn"], - clickHandler: function () {} - } -} -\`\`\` - -修正后: -\`\`\`json -{ - "componentName": "TinyButton", - "props": { - "className": "primary-btn", - "onClick": { - "type": "JSFunction", - "value": "function() { /* 处理逻辑 */ }" - } - } -} -\`\`\` - -# IPageSchema规范: -## 1. 页面结构要求 -- 每个组件必须包含componentName,componentName: "Page" | "div" | "Text" | "TinyInput" | "TinyButton" | "img" | "video" | "a";可参考知识生成 -- 每个组件必须包含唯一ID:ID必须是8位随机字符串(示例:"k8jD3fG2");字符集:a-z, A-Z, 0-9;必须包含至少1个字母和1个数字;禁止连续模式(错误示例:"abc12345");使用强随机性组合(如"x7Y2pQ9r");同一JSON中所有组件的ID必须绝对唯一 -- 层级关系通过children数组嵌套,"children"的值不允许生成纯字符串数组、"children"的值不允许生成数组中混合对象和字符串的数据格式 -- 动态数据使用 this.state.xxx 绑定 -- 事件处理使用 this.methods.xxx 绑定 -- 样式通过每个组件的props.style字段定义(字符串格式,与行内样式格式相同),注意背景颜色、文字颜色、字体大小、字体系列、填充、边距、边框、布局等,严格按照图片样式还原,准确匹配颜色和尺寸。建议多用弹性布局。 - -### 错误示例修正 -❌ 排序ID: "id": "12345678" -✅ 乱序ID: "id": "8264a1c3" - -## 2. 组件转换规则 -├─ 容器元素 → { componentName: "div", id: "1aw73542" } -├─ 表单元素 → { componentName: "TinyInput/TinySelect/TinyRadio", id: "162ee548" } -├─ 按钮元素 → { componentName: "TinyButton", id: "16qw3541" } -└─ 文本内容 → { componentName: "Text", id: "162731e8", props: { "text": "/** 文本内容 */" }} -└─ 图片/图像元素 → { componentName: "img", id: "1qwe3548", props: { "src": "/** 图片链接 */", "alt": "/** 图片名称 */" }} -└─ 视频元素 → { componentName: "video", id: "16173eq", props: { "src": "/** 视频链接 */", "autoPlay": true, "loop": true, "muted": true}} -└─ 链接跳转元素 → { componentName: "a", id: "16273op9", props: {"href": "/** 跳转链接 */", "target": "_self"}} - -## 3. 特殊属性处理 -条件渲染: { -"condition": { -"type": "JSExpression", -"value": "this.state.showSection" -} -} -事件绑定: { -"onClick": { -"type": "JSFunction", -"value": "function() { this.methods.handleSubmit() }" -} -} - -# 最终输出要求 -1. 必须通过以下校验: - \`\`\`javascript - JSON.parse(yourOutput) // 不能抛出语法错误 - \`\`\` -2. 占位资源使用: - - 图片: "src": "https://placehold.co/600x400" - - 视频: "src": "https://placehold.co/640x360.mp4" -3. 直接输出完整JSON,不要包含: - - 注释(如) - - 未实现的占位符(如...其他项目...) - - 任何非JSON文本 -` diff --git a/packages/plugins/robot/src/js/useRobot.ts b/packages/plugins/robot/src/js/useRobot.ts deleted file mode 100644 index a67b9d3cf8..0000000000 --- a/packages/plugins/robot/src/js/useRobot.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Copyright (c) 2023 - present TinyEngine Authors. - * Copyright (c) 2023 - present Huawei Cloud Computing Technologies Co., Ltd. - * - * Use of this source code is governed by an MIT-style license. - * - * THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL, - * BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR - * A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS. - * - */ - -/* metaService: engine.plugins.robot.useRobot */ -import { reactive } from 'vue' -import { getOptions, getMetaApi, META_SERVICE } from '@opentiny/tiny-engine-meta-register' -import meta from '../../meta' - -const EXISTING_MODELS = 'existingModels' -const CUSTOMIZE = 'customize' -const VISUAL_MODEL = ['qwen-vl-max', 'qwen-vl-plus'] -const AI_MODES = { Builder: 'builder', Chat: 'chat' } - -const AIModelOptions = [ - { - label: '阿里云百炼', - value: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - model: [ - { label: 'qwen-vl-max', value: 'qwen-vl-max', ability: ['visual'] }, - { label: 'qwen-vl-plus', value: 'qwen-vl-plus', ability: ['visual'] }, - { label: 'qwen-plus', value: 'qwen-plus' }, - { label: 'qwen-max', value: 'qwen-max' }, - { label: 'qwen-turbo', value: 'qwen-turbo' }, - { label: 'qwen-long', value: 'qwen-long' }, - { label: 'deepseek-r1', value: 'deepseek-r1' }, - { label: 'deepseek-v3', value: 'deepseek-v3' }, - { label: 'qwen2.5-14b-instruct', value: 'qwen2.5-14b-instruct' }, - { label: 'qwen2.5-7b-instruct', value: 'qwen2.5-7b-instruct' }, - { label: 'qwen2.5-coder-7b-instruct', value: 'qwen2.5-coder-7b-instruct' }, - { label: 'qwen2.5-omni', value: 'qwen2.5-omni' }, - { label: 'qwen3-14b', value: 'qwen3-14b' }, - { label: 'qwen3-8b', value: 'qwen3-8b' }, - { label: 'deepseek-r1-distill-qwen-1.5b', value: 'deepseek-r1-distill-qwen-1.5b' }, - { label: 'deepseek-r1-distill-qwen-32b', value: 'deepseek-r1-distill-qwen-32b' } - ] - }, - { - label: 'DeepSeek', - value: 'https://api.deepseek.com/v1', - model: [ - { label: 'deepseek-chat', value: 'deepseek-chat' }, - { label: 'deepseek-reasoner', value: 'deepseek-reasoner' } - ] - }, - { - label: '月之暗面', - value: 'https://api.moonshot.cn/v1', - model: [ - { label: 'moonshot-v1-8k', value: 'moonshot-v1-8k' }, - { label: 'moonshot-v1-32k', value: 'moonshot-v1-32k' }, - { label: 'moonshot-v1-128k', value: 'moonshot-v1-128k' } - ] - } -] - -const getAIModelOptions = () => { - const aiRobotOptions = getOptions(meta.id)?.customCompatibleAIModels || [] - return aiRobotOptions.length ? aiRobotOptions : AIModelOptions -} - -const robotSettingState = reactive({ - selectedModel: { - label: getAIModelOptions()[0].label, - activeName: EXISTING_MODELS, - baseUrl: getAIModelOptions()[0].value, - model: getAIModelOptions()[0].model[0].value, - completeModel: getAIModelOptions()[0].model[0].value || '', - apiKey: '' - } -}) - -// 这里存放的是aichat的响应式数据 -const state = reactive({ - blockList: [], - blockContent: '' -}) - -const getBlocks = () => state.blockList || [] - -const setBlocks = (blocks) => { - state.blockList = blocks -} - -const getBlockContent = () => state.blockContent || '' - -const transformBlockNameToElement = (label) => { - const elementName = label.replace(/[A-Z]/g, (letter, index) => { - return index === 0 ? letter.toLowerCase() : `_${letter.toLowerCase()}` - }) - return `<${elementName}>` -} - -// 拼接blockContent,在ai初始时引入区块。 -const setBlockContent = (list = getBlocks()) => { - const blockList = list.slice(0, 200) // 为了尽量避免每个请求的message内容过大,限制block的个数避免超出字节要求 - const blockMessages = blockList.map((item) => { - const blockElementName = transformBlockNameToElement(item.label) - return `${blockElementName}名称是${item.label}` - }) - const content = blockMessages?.join(';') - if (content) { - state.blockContent = `在提问之前,我希望你记住以下自定义的前端组件:${content}。接下来我开始问出第一个问题:` - } else { - state.blockContent = '' - } -} - -const initBlockList = async () => { - if (state.blockList?.length) { - return - } - const appId = getMetaApi(META_SERVICE.GlobalService).getBaseInfo().id - try { - const list = await getMetaApi(META_SERVICE.Http).get('/material-center/api/blocks', { params: { appId } }) - setBlocks(list) - setBlockContent(list) - } catch (err) { - // 捕获错误 - throw new Error('获取block列表失败', { cause: err }) - } -} - -const isValidOperation = (operation) => { - const allowedOps = ['add', 'remove', 'replace', 'move', 'copy', 'test', '_get'] - - if (typeof operation !== 'object' || operation === null) { - return false - } - // 检查操作类型是否有效 - if (!operation.op || !allowedOps.includes(operation.op)) { - return false - } - // 检查path字段是否存在且为字符串 - if (!operation.path || typeof operation.path !== 'string') { - return false - } - // 根据操作类型检查其他必需字段 - switch (operation.op) { - case 'add': - case 'replace': - case 'test': - if (!('value' in operation)) { - return false - } - break - case 'move': - case 'copy': - if (!operation.from || typeof operation.from !== 'string') { - return false - } - break - } - - return true -} - -const isValidFastJsonPatch = (patch) => { - if (Array.isArray(patch)) { - return patch.every(isValidOperation) - } else if (typeof patch === 'object' && patch !== null) { - return isValidOperation(patch) - } - return false -} - -export default () => { - return { - EXISTING_MODELS, - CUSTOMIZE, - VISUAL_MODEL, - AI_MODES, - AIModelOptions, - getAIModelOptions, - robotSettingState, - state, - getBlocks, - setBlocks, - getBlockContent, - transformBlockNameToElement, - setBlockContent, - initBlockList, - isValidOperation, - isValidFastJsonPatch - } -} diff --git a/packages/plugins/robot/src/js/utils.ts b/packages/plugins/robot/src/js/utils.ts deleted file mode 100644 index 80b69a9332..0000000000 --- a/packages/plugins/robot/src/js/utils.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { handleSSEStream, type StreamHandler } from '@opentiny/tiny-robot-kit' - -export const chatStream = async (requestOpts: any, handler: StreamHandler, headers = {}) => { - try { - const { requestData, requestUrl } = requestOpts - - const requestOptions = { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'text/event-stream', - ...headers - }, - body: JSON.stringify(requestData) - } - const response = await fetch(requestUrl, requestOptions) - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`HTTP error! status: ${response.status}, details: ${errorText}`) - } - - await handleSSEStream(response, handler) - } catch (error: unknown) { - const logger = console - logger.error('Error in chatStream:', error) - } -} - -export const checkComponentNameExists = (data: any) => { - if (!data.componentName) { - return false - } - - if (data.children && Array.isArray(data.children)) { - for (const child of data.children) { - if (!checkComponentNameExists(child)) { - return false - } - } - } - - return true -} - -export const processSSEStream = (data,handler) => { - const lines = data.split('\n') - - for (const line of lines) { - if (line.startsWith('data: ')) { - const dataStr = line.substring(6).trim() - - // 检查结束标记 - if (dataStr === '[DONE]') { - handler.onDone() - - return - } - - if (dataStr) { - const data = JSON.parse(dataStr) - handler.onData(data) - } - } - } -} \ No newline at end of file diff --git a/packages/plugins/robot/src/mcp/LoadingRenderer.vue b/packages/plugins/robot/src/mcp/LoadingRenderer.vue deleted file mode 100644 index d32def5b80..0000000000 --- a/packages/plugins/robot/src/mcp/LoadingRenderer.vue +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/packages/plugins/robot/src/mcp/utils.ts b/packages/plugins/robot/src/mcp/utils.ts deleted file mode 100644 index aa9fd4887c..0000000000 --- a/packages/plugins/robot/src/mcp/utils.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { toRaw } from 'vue' -import useMcpServer from './useMcp' -import type { LLMMessage, RobotMessage } from './types' -import type { LLMRequestBody, LLMResponse, ReponseToolCall, RequestOptions, RequestTool } from './types' -import { META_SERVICE, getMetaApi } from '@opentiny/tiny-engine-meta-register' - -let requestOptions: RequestOptions = {} - -// 格式化LLM输入messages消息 -const formatMessages = (messages: LLMMessage[]) => { - return messages.map((message) => ({ - role: message.role, - content: message.content - })) -} - -const fetchLLM = async (messages: LLMMessage[], tools: RequestTool[], options: RequestOptions = requestOptions) => { - const bodyObj: LLMRequestBody = { - baseUrl: options.baseUrl, - model: options?.model || 'deepseek-chat', - stream: false, - messages: toRaw(messages) - } - if (tools.length > 0) { - bodyObj.tools = toRaw(tools) - } - return getMetaApi(META_SERVICE.Http).post(options?.url || '/app-center/api/chat/completions', bodyObj, { - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } - }) -} - -const parseArgs = (args: string) => { - try { - return JSON.parse(args) - } catch (error) { - return args - } -} - -export const serializeError = (err: unknown): string => { - if (err instanceof Error) { - return JSON.stringify({ name: err.name, message: err.message }) - } - if (typeof err === 'string') return err - try { - return JSON.stringify(err) - } catch { - return String(err) - } -} - -const formatToolResult = ( - toolResult: string | { type: 'text'; text: string } | Array<{ type: 'text'; text: string }> -) => { - let result: any = toolResult - if (Array.isArray(result) && result.length === 1) { - result = result[0] - } - - if (typeof result === 'object' && result.type === 'text' && result.text) { - result = result.text - } - - if (typeof result === 'string') { - return result - } - - return JSON.stringify(result) -} - -const handleToolCall = async ( - res: LLMResponse, - tools: RequestTool[], - messages: RobotMessage[], - contextMessages?: RobotMessage[] -) => { - if (messages.length < 1) { - return - } - const currentMessage = messages.at(-1)! - if (!currentMessage.renderContent) { - currentMessage.renderContent = [] - } - if (res.choices[0].message.content) { - currentMessage.renderContent.push({ - type: 'markdown', - content: res.choices[0].message.content - }) - } - const tool_calls: ReponseToolCall[] | undefined = res.choices[0].message.tool_calls - if (tool_calls && tool_calls.length) { - const historyMessages = contextMessages?.length ? contextMessages : toRaw(messages.slice(0, -1)) - const toolMessages: LLMMessage[] = [...historyMessages, res.choices[0].message] as LLMMessage[] - for (const tool of tool_calls) { - const { name, arguments: args } = tool.function - const parsedArgs = parseArgs(args) - const currentToolMessage = { - type: 'tool', - name, - status: 'running', - content: { - params: parsedArgs - }, - formatPretty: true - } - currentMessage.renderContent.push(currentToolMessage) - let toolCallResult: string - let toolCallStatus: 'success' | 'failed' - try { - const resp = await useMcpServer().callTool(name, parsedArgs) - toolCallStatus = 'success' - toolCallResult = resp.content - } catch (error) { - toolCallStatus = 'failed' - toolCallResult = serializeError(error) - } - toolMessages.push({ - content: formatToolResult(toolCallResult), - role: 'tool', - tool_call_id: tool.id - }) - - currentMessage.renderContent.at(-1)!.status = toolCallStatus - currentMessage.renderContent.at(-1)!.content = { - params: parsedArgs, - result: toolCallResult - } - } - currentMessage.renderContent.push({ type: 'loading', content: '' }) - const newResp = await fetchLLM(toolMessages, tools) - currentMessage.renderContent.pop() - const hasToolCall = newResp.choices[0].message.tool_calls?.length > 0 - if (hasToolCall) { - await handleToolCall(newResp, tools, messages, toolMessages) - } else { - if (newResp.choices[0].message.content) { - currentMessage.renderContent.push({ - type: 'markdown', - content: newResp.choices[0].message.content - }) - } - } - } -} - -export const sendMcpRequest = async (messages: LLMMessage[], options: RequestOptions = {}) => { - if (messages.length < 1) { - return - } - const tools = await useMcpServer().getLLMTools() - requestOptions = options - messages.at(-1)!.renderContent = [{ type: 'loading', content: '' }] - const historyRaw = toRaw(messages.slice(0, -1)) as LLMMessage[] - const res = await fetchLLM(formatMessages(historyRaw), tools, options) - delete messages.at(-1)!.renderContent - const hasToolCall = res.choices[0].message.tool_calls?.length > 0 - if (hasToolCall) { - await handleToolCall(res, tools, messages) - const lastMsg: any = messages.at(-1) as any - const renderList: any[] | undefined = Array.isArray(lastMsg.renderContent) - ? (lastMsg.renderContent as any[]) - : undefined - const lastRendered: any = renderList && renderList.length > 0 ? renderList[renderList.length - 1] : undefined - const renderedContent: unknown = lastRendered?.content - lastMsg.content = typeof renderedContent === 'string' ? renderedContent : JSON.stringify(renderedContent ?? '') - } else { - messages.at(-1)!.content = res.choices[0].message.content - } -} diff --git a/packages/plugins/robot/src/js/index.ts b/packages/plugins/robot/src/metas/index.ts similarity index 51% rename from packages/plugins/robot/src/js/index.ts rename to packages/plugins/robot/src/metas/index.ts index a17eb974d9..383d54ee2c 100644 --- a/packages/plugins/robot/src/js/index.ts +++ b/packages/plugins/robot/src/metas/index.ts @@ -1,10 +1,13 @@ import { HOOK_NAME } from '@opentiny/tiny-engine-meta-register' -import useRobot from './useRobot' +import useModelConfig from '../composables/useConfig' export const RobotService = { id: 'engine.service.robot', type: 'MetaService', - apis: useRobot(), + apis: { + robotSettingState: useModelConfig().robotSettingState, + getAIModelOptions: useModelConfig().getAIModelOptions + }, composable: { name: HOOK_NAME.useRobot } diff --git a/packages/plugins/robot/src/prompts/data/components.json b/packages/plugins/robot/src/prompts/data/components.json new file mode 100644 index 0000000000..878c2aa355 --- /dev/null +++ b/packages/plugins/robot/src/prompts/data/components.json @@ -0,0 +1,998 @@ +[ + { + "component": "Box", + "name": "盒子容器", + "demo": { + "componentName": "div", + "props": {} + } + }, + { + "component": "Text", + "name": "文本", + "properties": ["text"], + "events": ["onClick"], + "demo": { + "componentName": "Text", + "props": { + "style": "display: inline-block;", + "text": "TinyEngine 前端可视化设计器,为设计器开发者提供定制服务,在线构建出自己专属的设计器。" + } + } + }, + { + "component": "Icon", + "name": "图标", + "properties": ["name"], + "events": ["onClick"], + "demo": { + "componentName": "Icon", + "props": { + "name": "IconDel" + } + } + }, + { + "component": "Img", + "name": "图片", + "properties": ["src"], + "events": ["onClick"], + "demo": { + "componentName": "Img", + "props": { + "src": "https://tinyengine-assets.obs.cn-north-4.myhuaweicloud.com/files/designer-default-icon.jpg" + } + } + }, + { + "component": "Slot", + "name": "插槽", + "properties": ["name", "params"], + "events": [], + "demo": { + "componentName": "Slot", + "props": {} + } + }, + { + "component": "RouterView", + "name": "路由视图", + "properties": [], + "demo": { + "componentName": "RouterView", + "props": {} + } + }, + { + "component": "RouterLink", + "name": "路由链接", + "properties": ["to", "activeClass", "exactActiveClass"], + "demo": { + "componentName": "RouterLink", + "props": {}, + "children": [ + { + "componentName": "Text", + "props": { + "text": "路由文本" + } + } + ] + } + }, + { + "component": "TinyLayout", + "name": "栅格布局", + "properties": ["cols", "tag"], + "demo": { + "componentName": "TinyLayout", + "props": {}, + "children": [ + { + "componentName": "TinyRow", + "props": { + "style": "padding: 10px;" + }, + "children": [ + { + "componentName": "TinyCol", + "props": { + "span": 3 + } + }, + { + "componentName": "TinyCol", + "props": { + "span": 3 + } + }, + { + "componentName": "TinyCol", + "props": { + "span": 3 + } + }, + { + "componentName": "TinyCol", + "props": { + "span": 3 + } + } + ] + }, + { + "componentName": "TinyRow", + "props": { + "style": "padding: 10px;" + }, + "children": [ + { + "componentName": "TinyCol", + "props": { + "span": 3 + } + }, + { + "componentName": "TinyCol", + "props": { + "span": 3 + } + }, + { + "componentName": "TinyCol", + "props": { + "span": 3 + } + }, + { + "componentName": "TinyCol", + "props": { + "span": 3 + } + } + ] + } + ] + } + }, + { + "component": "TinyButton", + "name": "按钮", + "properties": ["text", "size", "disabled", "type"], + "events": ["onClick"], + "demo": { + "componentName": "TinyButton", + "props": { + "text": "按钮文案" + } + } + }, + { + "component": "TinyButtonGroup", + "name": "互斥按钮组", + "properties": ["data", "size", "plain", "disabled"], + "events": [], + "demo": { + "componentName": "TinyButtonGroup", + "props": { + "data": [ + { + "text": "Button1", + "value": "1" + }, + { + "text": "Button2", + "value": "2" + }, + { + "text": "Button3", + "value": "3" + } + ], + "modelValue": "1" + } + } + }, + { + "component": "TinySearch", + "name": "搜索框", + "properties": ["modelValue", "disabled", "placeholder", "clearable", "isEnterSearch"], + "events": ["onChange", "onSearch"], + "demo": { + "componentName": "TinySearch", + "props": { + "modelValue": "", + "placeholder": "输入关键词" + } + } + }, + { + "component": "TinyForm", + "name": "表单", + "properties": ["disabled", "label-width", "inline", "label-align", "label-suffix", "label-position"], + "events": ["onValidate", "onInput", "onBlur", "onFocus", "onClear"], + "demo": { + "componentName": "TinyForm", + "props": { + "labelWidth": "80px", + "labelPosition": "top" + }, + "children": [ + { + "componentName": "TinyFormItem", + "props": { + "label": "人员" + }, + "children": [ + { + "componentName": "TinyInput", + "props": { + "placeholder": "请输入", + "modelValue": "" + } + } + ] + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "密码" + }, + "children": [ + { + "componentName": "TinyInput", + "props": { + "placeholder": "请输入", + "modelValue": "", + "type": "password" + } + } + ] + }, + { + "componentName": "TinyFormItem", + "props": { + "label": "" + }, + "children": [ + { + "componentName": "TinyButton", + "props": { + "text": "提交", + "type": "primary", + "style": "margin-right: 10px" + } + }, + { + "componentName": "TinyButton", + "props": { + "text": "重置", + "type": "primary" + } + } + ] + } + ] + } + }, + { + "component": "TinySelect", + "name": "下拉框", + "properties": ["modelValue", "placeholder", "clearable", "searchable", "disabled", "options", "multiple"], + "events": ["onChange", "onUpdate:modelValue", "onBlur", "onFocus", "onClear", "onRemoveTag"], + "demo": { + "componentName": "TinySelect", + "props": { + "modelValue": "", + "placeholder": "请选择", + "options": [ + { + "value": "1", + "label": "黄金糕" + }, + { + "value": "2", + "label": "双皮奶" + } + ] + } + } + }, + { + "component": "TinySwitch", + "name": "开关", + "properties": ["disabled", "modelValue", "true-value", "false-value", "mini"], + "events": ["onChange", "onUpdate:modelValue"], + "demo": { + "componentName": "TinySwitch", + "props": { + "modelValue": "" + } + } + }, + { + "component": "TinyCheckboxGroup", + "name": "复选框组", + "properties": ["modelValue", "disabled", "options", "type"], + "events": ["onChange", "onUpdate:modelValue"], + "demo": { + "componentName": "TinyCheckboxGroup", + "props": { + "modelValue": ["name1", "name2"], + "type": "checkbox", + "options": [ + { + "text": "复选框1", + "label": "name1" + }, + { + "text": "复选框2", + "label": "name2" + }, + { + "text": "复选框3", + "label": "name3" + } + ] + } + } + }, + { + "component": "TinyInput", + "name": "输入框", + "properties": ["modelValue", "type", "rows", "placeholder", "clearable", "disabled", "size"], + "events": ["onChange", "onInput", "onUpdate:modelValue", "onBlur", "onFocus", "onClear"], + "slots": ["prefix", "suffix"], + "demo": { + "componentName": "TinyInput", + "props": { + "placeholder": "请输入", + "modelValue": "" + } + } + }, + { + "component": "TinyRadio", + "name": "单选", + "properties": ["text", "label", "modelValue", "disabled"], + "events": ["onChange", "onUpdate:modelValue"], + "demo": { + "componentName": "TinyRadio", + "props": { + "label": "1", + "text": "单选文本" + } + } + }, + { + "component": "TinyCheckbox", + "name": "复选框", + "properties": ["modelValue", "disabled", "checked", "text"], + "events": ["onChange", "onUpdate:modelValue"], + "demo": { + "componentName": "TinyCheckbox", + "props": { + "text": "复选框文案" + } + } + }, + { + "component": "TinyDatePicker", + "name": "日期选择", + "properties": ["modelValue", "type", "placeholder", "clearable", "disabled", "readonly", "size"], + "events": ["onChange", "onInput", "onUpdate:modelValue", "onBlur", "onFocus", "onClear"], + "demo": { + "componentName": "TinyDatePicker", + "props": { + "placeholder": "请输入", + "modelValue": "" + } + } + }, + { + "component": "TinyNumeric", + "name": "数字输入框", + "properties": [ + "modelValue", + "placeholder", + "allow-empty", + "disabled", + "size", + "controls", + "controls-position", + "precision", + "step", + "max", + "min" + ], + "events": ["onChange", "onInput", "onUpdate:modelValue", "onBlur", "onFocus", "onClear"], + "demo": { + "componentName": "TinyNumeric", + "props": { + "allow-empty": true, + "placeholder": "请输入", + "controls-position": "right", + "step": 1 + } + } + }, + { + "component": "TinyTransfer", + "name": "穿梭框", + "properties": ["modelValue", "data", "filterable", "showAllBtn", "toLeftDisable", "toRightDisable", "titles"], + "events": ["onChange", "onLeftCheckChange", "onRightCheckChange"], + "demo": { + "componentName": "TinyTransfer", + "props": { + "modelValue": [3], + "data": [ + { + "key": 1, + "label": "备选项1", + "disabled": false + }, + { + "key": 2, + "label": "备选项2", + "disabled": false + }, + { + "key": 3, + "label": "备选项3", + "disabled": false + }, + { + "key": 4, + "label": "备选项4", + "disabled": false + } + ] + } + } + }, + { + "component": "TinyGrid", + "name": "表格", + "properties": [ + "data", + "columns", + "fetchData", + "pager", + "resizable", + "row-id", + "select-config", + "edit-rules", + "edit-config", + "expand-config", + "sortable" + ], + "events": [ + "onFilterChange", + "onSortChange", + "onSelectAll", + "onSelectChange", + "onToggleExpandChange", + "onCurrentChange" + ], + "demo": { + "componentName": "TinyGrid", + "props": { + "editConfig": { + "trigger": "click", + "mode": "cell", + "showStatus": true + }, + "columns": [ + { + "type": "index", + "width": 60 + }, + { + "type": "selection", + "width": 60 + }, + { + "field": "employees", + "title": "员工数" + }, + { + "field": "created_date", + "title": "创建日期" + }, + { + "field": "city", + "title": "城市" + } + ], + "data": [ + { + "id": "1", + "name": "GFD科技有限公司", + "city": "福州", + "employees": 800, + "created_date": "2014-04-30 00:56:00", + "boole": false + }, + { + "id": "2", + "name": "WWW科技有限公司", + "city": "深圳", + "employees": 300, + "created_date": "2016-07-08 12:36:22", + "boole": true + } + ] + } + } + }, + { + "component": "TinyPager", + "name": "分页", + "properties": ["currentPage", "pageSize", "pageSizes", "total", "layout"], + "events": ["onCurrentChange ", "onPrevClick ", "onNextClick"], + "demo": { + "componentName": "TinyPager", + "props": { + "layout": "total, sizes, prev, pager, next", + "total": 100, + "pageSize": 10, + "currentPage": 1 + } + } + }, + { + "component": "TinyCarousel", + "name": "走马灯", + "properties": [ + "arrow", + "autoplay", + "tabs", + "height", + "indicator-position", + "initial-index", + "interval", + "loop", + "show-title", + "trigger", + "type" + ], + "events": [], + "demo": { + "componentName": "TinyCarousel", + "props": { + "height": "180px" + }, + "children": [ + { + "componentName": "TinyCarouselItem", + "props": { + "title": "carousel-item-a" + }, + "children": [ + { + "componentName": "div", + "props": { + "style": "margin:10px 0 0 30px" + } + } + ] + }, + { + "componentName": "TinyCarouselItem", + "props": { + "title": "carousel-item-b" + }, + "children": [ + { + "componentName": "div", + "props": { + "style": "margin:10px 0 0 30px" + } + } + ] + } + ] + } + }, + { + "component": "TinyDialogBox", + "name": "对话框", + "properties": ["title", "visible", "width", "draggable", "center", "dialog-class", "append-to-body", "show-close"], + "events": ["onClose", "onUpdate:visible"], + "slots": ["title", "footer"], + "demo": { + "componentName": "TinyDialogBox", + "props": { + "visible": true, + "show-close": true, + "title": "dialogBox title" + }, + "children": [ + { + "componentName": "div" + } + ] + } + }, + { + "component": "TinyCollapse", + "name": "折叠面板", + "properties": ["modelValue"], + "events": ["onChange", "onUpdate:modelValue"], + "demo": { + "componentName": "TinyCollapse", + "props": { + "modelValue": "collapse1" + }, + "children": [ + { + "componentName": "TinyCollapseItem", + "props": { + "name": "collapse1", + "title": "折叠项1" + }, + "children": [ + { + "componentName": "div" + } + ] + }, + { + "componentName": "TinyCollapseItem", + "props": { + "name": "collapse2", + "title": "折叠项2" + }, + "children": [ + { + "componentName": "div" + } + ] + }, + { + "componentName": "TinyCollapseItem", + "props": { + "name": "collapse3", + "title": "折叠项3" + }, + "children": [ + { + "componentName": "div" + } + ] + } + ] + } + }, + { + "component": "TinyPopeditor", + "name": "弹出编辑", + "properties": ["modelValue", "placeholder", "show-clear-btn", "disabled", "auto-lookup"], + "events": ["onChange", "onUpdate:modelValue", "onClose", "onPageChange"], + "demo": { + "componentName": "TinyPopeditor", + "props": { + "modelValue": "", + "placeholder": "请选择", + "grid-op": { + "columns": [ + { + "field": "id", + "title": "ID", + "width": 40 + }, + { + "field": "name", + "title": "名称", + "showOverflow": "tooltip" + }, + { + "field": "province", + "title": "省份", + "width": 80 + }, + { + "field": "city", + "title": "城市", + "width": 80 + } + ], + "data": [ + { + "id": "1", + "name": "GFD科技有限公司GFD科技有限公司GFD科技有限公司GFD科技有限公司GFD科技有限公司GFD科技有限公司GFD科技有限公司", + "city": "福州", + "province": "福建" + }, + { + "id": "2", + "name": "WWW科技有限公司", + "city": "深圳", + "province": "广东" + }, + { + "id": "3", + "name": "RFV有限责任公司", + "city": "中山", + "province": "广东" + }, + { + "id": "4", + "name": "TGB科技有限公司", + "city": "龙岩", + "province": "福建" + }, + { + "id": "5", + "name": "YHN科技有限公司", + "city": "韶关", + "province": "广东" + }, + { + "id": "6", + "name": "WSX科技有限公司", + "city": "黄冈", + "province": "武汉" + } + ] + } + } + } + }, + { + "component": "TinyTree", + "name": "树", + "properties": [ + "show-checkbox", + "data", + "node-key", + "render-content", + "icon-trigger-click-node", + "expand-icon", + "shrink-icon" + ], + "events": ["onCheck", "onNodeClick"], + "demo": { + "componentName": "TinyTree", + "props": { + "data": [ + { + "label": "一级 1", + "children": [ + { + "label": "二级 1-1", + "children": [ + { + "label": "三级 1-1-1" + } + ] + } + ] + }, + { + "label": "一级 2", + "children": [ + { + "label": "二级 2-1", + "children": [ + { + "label": "三级 2-1-1" + } + ] + }, + { + "label": "二级 2-2", + "children": [ + { + "label": "三级 2-2-1" + } + ] + } + ] + } + ] + } + } + }, + { + "component": "TinyTooltip", + "name": "文字提示框", + "properties": ["placement", "content", "render-content", "modelValue", "manual"], + "events": [], + "slots": ["content"], + "demo": { + "componentName": "TinyTooltip", + "props": { + "content": "Top Left 提示文字", + "placement": "top-start", + "manual": true, + "modelValue": true + }, + "children": [ + { + "componentName": "span", + "children": [ + { + "componentName": "div", + "props": {} + } + ] + }, + { + "componentName": "Template", + "props": { + "slot": "content" + }, + "children": [ + { + "componentName": "span", + "children": [ + { + "componentName": "div", + "props": { + "placeholder": "提示内容" + } + } + ] + } + ] + } + ] + } + }, + { + "component": "TinyPopover", + "name": "提示框", + "properties": [ + "modelValue", + "placement", + "trigger", + "popper-class", + "visible-arrow", + "append-to-body", + "arrow-offset", + "close-delay", + "content", + "disabled", + "offset", + "open-delay", + "popper-options", + "title", + "transform-origin", + "transition", + "width" + ], + "events": ["onUpdate:modelValue"], + "demo": { + "componentName": "TinyPopover", + "props": { + "width": 200, + "title": "弹框标题", + "trigger": "manual", + "modelValue": true + }, + "children": [ + { + "componentName": "Template", + "props": { + "slot": "reference" + }, + "children": [ + { + "componentName": "div", + "props": { + "placeholder": "触发源" + } + } + ] + }, + { + "componentName": "Template", + "props": { + "slot": "default" + }, + "children": [ + { + "componentName": "div", + "props": { + "placeholder": "提示内容" + } + } + ] + } + ] + } + }, + { + "component": "TinyTimeLine", + "name": "时间线", + "properties": ["vertical", "active", "data"], + "events": ["onClick"], + "demo": { + "componentName": "TinyTimeLine", + "props": { + "active": "2", + "data": [ + { + "name": "已下单" + }, + { + "name": "运输中" + }, + { + "name": "已签收" + } + ] + } + } + }, + { + "component": "TinyBreadcrumb", + "name": "面包屑", + "properties": ["separator", "options", "textField"], + "events": ["onSelect"], + "demo": { + "componentName": "TinyBreadcrumb", + "props": { + "options": [ + { + "to": "{ path: '/' }", + "label": "首页" + }, + { + "to": "{ path: '/breadcrumb' }", + "label": "产品" + }, + { + "replace": "true", + "label": "软件" + } + ] + } + } + }, + { + "component": "TinyTabs", + "name": "标签页", + "properties": ["tabs", "modelValue", "with-add", "with-close", "tab-style"], + "events": ["onClick", "onEdit", "onClose"], + "demo": { + "componentName": "TinyTabs", + "props": { + "modelValue": "first" + }, + "children": [ + { + "componentName": "TinyTabItem", + "props": { + "title": "标签页1", + "name": "first" + }, + "children": [ + { + "componentName": "div", + "props": { + "style": "margin:10px 0 0 30px" + } + } + ] + }, + { + "componentName": "TinyTabItem", + "props": { + "title": "标签页2", + "name": "second" + }, + "children": [ + { + "componentName": "div", + "props": { + "style": "margin:10px 0 0 30px" + } + } + ] + } + ] + } + } +] diff --git a/packages/plugins/robot/src/prompts/data/examples.json b/packages/plugins/robot/src/prompts/data/examples.json new file mode 100644 index 0000000000..c5bbf1eb46 --- /dev/null +++ b/packages/plugins/robot/src/prompts/data/examples.json @@ -0,0 +1,148 @@ +{ + "chatMessageList": { + "name": "Chat Message List Example", + "description": "A complete example of adding a chat message list with input and send functionality", + "note": "All JavaScript code uses string concatenation, not template literals", + "patch": [ + { + "op": "add", + "path": "/state/messages", + "value": [ + { + "content": "hello" + } + ] + }, + { + "op": "add", + "path": "/state/inputMessage", + "value": "" + }, + { + "op": "add", + "path": "/children/0", + "value": { + "componentName": "div", + "id": "25153243", + "props": { + "className": "component-base-style" + }, + "children": [ + { + "componentName": "h1", + "props": { + "className": "component-base-style" + }, + "children": "消息列表", + "id": "53222591" + }, + { + "componentName": "div", + "props": { + "className": "component-base-style div-uhqto", + "alignItems": "flex-start" + }, + "children": [ + { + "componentName": "div", + "props": { + "className": "component-base-style div-vinko", + "onClick": { + "type": "JSExpression", + "value": "this.onClickMessage", + "params": ["message", "index"] + }, + "key": { + "type": "JSExpression", + "value": "index" + } + }, + "children": [ + { + "componentName": "Text", + "props": { + "style": "display: inline-block;", + "text": { + "type": "JSExpression", + "value": "message.content" + }, + "className": "component-base-style" + }, + "children": [], + "id": "43312441" + } + ], + "id": "f2525253", + "loop": { + "type": "JSExpression", + "value": "this.state.messages" + }, + "loopArgs": ["message", "index"] + } + ], + "id": "544265d9" + }, + { + "componentName": "div", + "props": { + "className": "component-base-style div-iarpn" + }, + "children": [ + { + "componentName": "TinyInput", + "props": { + "placeholder": "请输入", + "modelValue": { + "type": "JSExpression", + "value": "this.state.inputMessage", + "model": true + }, + "className": "component-base-style", + "type": "textarea" + }, + "children": [], + "id": "24651354" + }, + { + "componentName": "TinyButton", + "props": { + "text": "发送", + "className": "component-base-style", + "onClick": { + "type": "JSExpression", + "value": "this.sendMessage" + } + }, + "children": [], + "id": "46812433" + } + ], + "id": "3225416b" + } + ] + } + }, + { + "op": "replace", + "path": "/css", + "value": ".page-base-style {\n padding: 24px;\n background: #ffffff;\n}\n.block-base-style {\n margin: 16px;\n}\n.component-base-style {\n margin: 8px;\n}\n.div-vinko {\n margin: 8px;\n border-width: 1px;\n border-color: #ebeaea;\n border-style: solid;\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n border-bottom-left-radius: 0;\n border-bottom-right-radius: 0;\n border-radius: 50px;\n}\n.div-iarpn {\n margin: 8px;\n display: flex;\n align-items: center;\n}\n.div-uhqto {\n margin: 8px;\n display: flex;\n flex-direction: column;\n}\n" + }, + { + "op": "add", + "path": "/methods/sendMessage", + "value": { + "type": "JSFunction", + "value": "function sendMessage(event) {\n this.state.messages.push({ content: this.state.inputMessage })\n this.state.inputMessage = ''\n}\n" + } + }, + { + "op": "add", + "path": "/methods/onClickMessage", + "value": { + "type": "JSFunction", + "value": "function onClickMessage(event, message, index) {\n console.log('这是第' + (index + 1) + '条消息, 消息内容:' + message.content)\n}\n" + } + } + ] + } +} diff --git a/packages/plugins/robot/src/prompts/index.ts b/packages/plugins/robot/src/prompts/index.ts new file mode 100644 index 0000000000..970bd0d7db --- /dev/null +++ b/packages/plugins/robot/src/prompts/index.ts @@ -0,0 +1,87 @@ +import agentPrompt from './templates/agent-prompt.md?raw' +import chatPrompt from './templates/chat-prompt.md?raw' +import componentsData from './data/components.json' +import examplesData from './data/examples.json' + +/** + * Convert components array to JSONL format string + */ +const formatComponentsToJsonl = (components: any[]): string => { + return '```jsonl\n' + components.map((comp) => JSON.stringify(comp)).join('\n') + '\n```' +} + +/** + * Format examples object to readable text + */ +const formatExamples = (examples: Record): string => { + return Object.entries(examples) + .map(([_key, example]) => { + const { name, description, note, patch } = example + const header = `### ${name}\n${description ? `${description}\n` : ''}${note ? `**Note**: ${note}\n` : ''}` + const patchContent = JSON.stringify(patch) + return `${header}\n${patchContent}` + }) + .join('\n\n') +} + +/** + * Generate agent system prompt with dynamic components and examples + */ +export const getAgentSystemPrompt = (currentPageSchema: object, referenceContext: string, imageAssets: any[]) => { + // Format components list + const ignoreComponents = ['TinyNumeric'] // 组件报错,先忽略 + const componentsList = formatComponentsToJsonl( + componentsData.filter((component) => !ignoreComponents.includes(component.component)) + ) + + // Format examples section + const examplesSection = formatExamples(examplesData) + + // Format current page schema + const currentPageSchemaStr = JSON.stringify(currentPageSchema) + + // Replace all placeholders in the prompt template + const prompt = agentPrompt + .replace('{{COMPONENTS_LIST}}', componentsList) + .replace('{{EXAMPLES_SECTION}}', examplesSection) + .replace('{{CURRENT_PAGE_SCHEMA}}', currentPageSchemaStr) + .replace('{{REFERENCE_KNOWLEDGE}}', referenceContext || '') + .replace('{{IMAGE_ASSETS}}', imageAssets.map((item) => `- ![${item.describe}](${item.url})`).join('\n')) + + return prompt.trim() +} + +export const getChatSystemPrompt = () => chatPrompt + +export const getJsonFixPrompt = (jsonString: string, error = '') => { + const errorSection = error ? `## Error Message\n${error}\n\n` : '' + + return ` +You are a JSON repair specialist. Fix the following invalid JSON string to create a valid JSON Patch array (RFC 6902 standard). + +## JSON Patch Format Requirements: +- Array of objects, each with required "op" and "path" properties +- "op" must be one of: "add", "replace", "remove", "move", "copy", "test" +- "path" must be a JSON Pointer string (e.g., "/property", "/array/0") +- "value" is required for "add", "replace", "move", "copy", "test" operations +- "from" is required for "move", "copy" operations +- All strings must use double quotes, no trailing commas + +## Example JSON Patch: +[ + { "op": "add", "path": "/children/0", "value": { ... } }, + { "op": "replace", "path": "/css", "value": "..." } +] + +## Your Task: +1. Parse and fix the invalid JSON string +2. Ensure it conforms to JSON Patch format +3. Output ONLY the corrected JSON string +4. No explanations, comments, or markdown formatting + +## Invalid JSON Input: +${jsonString} + +${errorSection}## Output (JSON only): +`.trim() +} diff --git a/packages/plugins/robot/src/prompts/templates/agent-prompt.md b/packages/plugins/robot/src/prompts/templates/agent-prompt.md new file mode 100644 index 0000000000..65b7dfbed0 --- /dev/null +++ b/packages/plugins/robot/src/prompts/templates/agent-prompt.md @@ -0,0 +1,213 @@ +**[System Instructions: Role & Core Mission]** + +You are a specialized AI assistant for a TinyEngine low-code platform. Your sole responsibility is to **function as an API that silently and precisely generates JSON Patch data for PageSchema structures**. You are not a conversational agent, but a functional service. + +**Core Mission**: Based on the **[Current Page Schema]**, **[Reference Knowledge]**, and user requirements, generate a strictly compliant `RFC 6902` JSON Patch array to add/replace/remove/move (`add`/`replace`/`remove`/`move`) components and logic that conform to the PageSchema specification (see Section 3), transforming the existing page into one that meets user needs. + +**⚠️ Critical Reminder**: Your output will be directly parsed by `JSON.parse()`. Any formatting errors will cause system crashes. You MUST: + 1. NEVER use JavaScript template literals (backticks `` ` ``), use string concatenation instead + 2. All newlines MUST be escaped as `\n`, no actual line breaks allowed + 3. Output pure JSON only, without any markers or comments + +----- + +## 1. Operational Workflow + +The low-code platform workflow is as follows: +Current Page Schema → Generate JSON Patch based on user requirements → Apply JSON Patch to create new Page Schema → Continue modifications based on user feedback, generating new JSON Patch from updated Schema → Apply new JSON Patch to update current Page Schema + +Page Schema is a JSON format describing page UI and functionality. It can be compiled into Vue code, so Page Schema is equivalent to Vue Single File Component code in a specific format. + +Follow these steps strictly to generate PageSchema (in JSON Patch format) that meets user requirements: + +1. **Parse Input**: Carefully analyze the **[User Requirements]** (text description or image analysis results), combined with the **[Current Page Schema]** below and any **[Reference Knowledge]** provided. +2. **Generate UI, Logic, Lifecycles, etc.**: Based on user requirements, think about modifications to the current Schema to generate UI, logic, lifecycles, and other necessary data that satisfies requirements and conforms to `PageSchema` specification. +3. **Encapsulate as JSON Patch**: Wrap the generated data into a strictly `RFC 6902` compliant JSON Patch array. Format example: `[{ "op": "add", "path": "/children/0", "value": { ... } }, {"op":"add","path":"/methods/handleBtnClick","value": { ... }}, { "op": "replace", "path": "/css", "value": "..." }]`. +4. **Final Validation**: Before output, execute the following verification steps: + - Confirm output is a **single-line** compact JSON string (no actual line breaks) + - Confirm all newlines within strings are escaped as `\n`, not actual newline characters + - Confirm NO JavaScript template literal syntax (backticks `` ` ``) is used + - Confirm all double quotes are properly escaped + - **[NEW] Check array element separation**: Search entire output to ensure no `]}{` patterns exist, should be `]},{` + - **[NEW] Check object separation**: Search entire output to ensure no `},"op":` patterns exist, should be `},{"op":` + - **[NEW] Check bracket balance**: Count `{` and `}` must be equal, `[` and `]` must be equal + - **[NEW] Check nesting depth**: Simulate bracket matching from start to end, ensure depth never goes negative + - Mentally simulate executing `JSON.parse(your_output)`, ensure it won't throw `SyntaxError` + - If any step fails or you cannot understand the requirement, you MUST output an empty array `[]`. + +----- + +## 2. Output Format & Absolute Constraints + +**Output in JSON format. You must and can only output a raw and complete JSON string, which is itself a JSON Patch array that can be parsed by JSON.parse into a JSON object.** For example, the following result adds a method named `handleBtnClick`, adds a page state variable named `name`, and removes a page element: +[{"op":"add","path":"/methods/handleBtnClick","value":{"type":"JSFunction","value":"function handleBtnClick() {\n console.log('button click')\n}\n"}},{"op":"add","path":"/state/name","value":"alice"},{"op":"remove","path":"/children/0/children/5"}] + +Constraint Rules: + * **Strictly Prohibited**: + * Any explanatory text, preamble, or closing remarks (e.g., "Here's the JSON you requested...") + * DO NOT wrap JSON string with \`\`\`json or \`\`\` + * Adding any comments inside or outside JSON (such as `//` or `/* */`) + * Any form of ellipsis or incomplete placeholders (such as `...`) + * **JSON Syntax Iron Rules**: + * All keys and string values MUST use **double quotes** (`"`) + * The last element of an object or array **MUST NOT** have trailing commas + * Boolean values must be lowercase `true` or `false`, not strings + * Ensure all brackets `{}`, `[]` are properly closed and matched + * Output MUST be a **single-line** compact JSON string, no actual line breaks or unnecessary spaces + * **Array and Object Separation Rules (Extremely Important!)**: + * **Array elements MUST have commas between them**: + * ❌ Fatal error: `[{...}{...}]` or `[{...}]{...}]` or `...]}{"componentName"...` + * ✅ Correct: `[{...},{...}]` or `...},{{"componentName"...` + * **Special attention**: Each child component in `children` array MUST have commas between them! + * **Check pattern**: NEVER allow `]}{` pattern, should be `]},{` + * **JSON Patch objects MUST be properly separated**: + * ❌ Fatal error: `{"op":"add",...},"op":"add"` (duplicate op field in same object) + * ✅ Correct: `{"op":"add",...},{"op":"add",...}` + * **Check pattern**: NEVER allow `},"op":` pattern after object ends, should be `},{"op":` + * **Brackets MUST be strictly balanced**: + * After generation, MUST check `{` and `}` counts are equal, `[` and `]` counts are equal + * Be extra careful with deep nesting, every `]` and `}` must have corresponding opening bracket + * NEVER allow extra closing brackets + * **String Escaping Iron Rules** (Critical! Avoid JSON.parse failure): + * All special characters in string values within JSON MUST be properly escaped: + * Double quotes escape as `\"` + * Backslashes escape as `\\` + * Newlines escape as `\n` (not actual line breaks) + * Tabs escape as `\t` + * **Strictly PROHIBIT JavaScript template literals** (backticks `` ` ``) syntax, use string concatenation or regular quotes: + * ❌ Wrong: `"console.log(\`hello ${name}\`)"` + * ✅ Correct: `"console.log('hello ' + name)"` + * In JavaScript code strings, prefer single quotes for string literals to avoid escaping double quotes + * Newlines in CSS style strings MUST be escaped as `\n` + * **Placeholder Resources**: When placeholder resources are needed, use these links: + * Images: `"src": "https://placehold.co/600x400"` + * Videos: `"src": "https://placehold.co/640x360.mp4"` + * Others + * Each new component must have a compliant, unique 8-character random ID. + +### 2.1 Common Error Examples (Absolutely Prohibited) + +To avoid JSON.parse failures, here are common errors with correct alternatives: + +**❌ Wrong Example 1**: Using JavaScript template literals (causes JSON parse failure) +``` +{"value":"function test(name) { console.log(`hello ${name}`) }"} +``` + +**✅ Correct Example 1**: Using string concatenation +``` +{"value":"function test(name) { console.log('hello ' + name) }"} +``` + +**❌ Wrong Example 2**: Contains actual line breaks (causes JSON parse failure) +``` +{"value":"function test() { + console.log('hello') +}"} +``` + +**✅ Correct Example 2**: Properly escape newlines as `\n` +``` +{"value":"function test() {\n console.log('hello')\n}"} +``` + +**❌ Wrong Example 3**: Using code block markers +```json +[{"op":"add","path":"/state/name","value":"test"}] +``` + +**✅ Correct Example 3**: Pure JSON output, no markers +``` +[{"op":"add","path":"/state/name","value":"test"}] +``` + +**❌ Wrong Example 4**: Unescaped double quotes in strings +``` +{"value":"function test() { console.log(\"hello\") }"} +``` + +**✅ Correct Example 4**: Use single quotes or properly escape double quotes +``` +{"value":"function test() { console.log('hello') }"} +``` + +----- + +## 3. PageSchema Specification + +**All components generated in `value` fields MUST conform to this specification.** + +### 3.1 Basic Structure + +Page `PageSchema` consists of nested children components, page state, global styles (css), page methods, page lifecycles, etc. The `PageSchema` interface is defined as: +```ts +interface PageSchema { // Page or block schema + css?: string; // Global page style class definitions, similar to in Vue, example: "css": ".page-base-style {\n padding: 24px;background: #FFFFFF;\n}\n\n.block-base-style {\n margin: 16px;\n}\n\n.component-base-style {\n margin: 8px;\n}\n", referenced in components via props.class + props: { + className?: string; // Style class names bound to page root node, multiple classes separated by spaces, can use style classes defined in PageSchema or Tailwind classes, e.g.: "className": "page-base-style" + }; + children?: Array | string; // Nested child components array or text string, ComponentSchema interface format defined below + state?: { + [name:string]: any; // State variables with initial values, e.g.: "stateName": "alice", state is like reactive variables in Vue: const state = reactive({ [name]: xxx }), accessed via this.state[name] + }; + methods?: { + [name:string]: { type: 'JSFunction', value: string } // Define methods, e.g.: "modelChange": { "type": "JSFunction", "value": "function modelChange(value) {\n this.emit('change', value);\n}" }, accessed via this[methodName] + } + lifeCycles: { + [name:string]: { type: 'JSFunction', value: string } // Define page lifecycles, similar to Vue component lifecycles, lifecycle name values enum: ['setup', 'onBeforeMount', 'onMounted', 'onUnmounted', 'onUpdated', 'onBeforeUpdate'], example: { "setup": { "type": "JSFunction", "value": "function({props, state, watch, onMounted }) {\n onMounted(() => {\n this.state.checkList = this.props.options.filter(item => item.checked).map(item => item[this.props.label]);\n this.state.checkOptions = this.props.options.filter(item => item.checked);\n })\n}" } } + } +} +``` + +Page component `ComponentSchema` interface is defined as: +```ts +interface ComponentSchema { // Component schema + componentName?: string; // Component name, available component names reference Section 3.3 + id: string; // Component ID, each component has a unique 8-character random ID, MUST contain at least one uppercase letter, one lowercase letter, and one digit, with strong randomness, good example: "a7Kp2sN9", bad example: "1234abcd" + props?: { // Component bound properties + condition?: boolean | IBindProps; // Conditional rendering, can combine with JSExpression for dynamic rendering scenarios or directly assign boolean. condition effect similar to v-if in Vue, e.g.: "condition": { "type": "JSExpression", "value": "this.state.visible" } equivalent to v-if="state.visible" + style?: string; // Component inline styles, e.g.: "style": "display: flex; align-items: center;" + className?: string; // Bound style class names, multiple classes separated by spaces, can use style classes defined in PageSchema or Tailwind classes, e.g.: "className": "component-base-style size-48 shadow-xl rounded-md" + [prop:string]?: IEventProps | IBindProps | any; // Component property names (including properties and events) with values, for setting regular property values or binding dynamic properties or binding events. Property values can be regular JS constants (number/boolean/object/array etc.), or { type,value} format to bind to variables/methods (starting with this.), example: { "total": 100, "fetch-data": { "type": "JSExpression", "value": "{api:this.getTableData}" }, "onClick": { "type": "JSExpression", "value": "this.fixedLayout" } } + }; + children?: Array | string; // Nested tree structure, can contain multiple ComponentSchema or text string, e.g. {"componentName":"div","children":[{"componentName":"div","children":"hello"}]} +} +``` + +### 3.2 Advanced Features + +- Dynamic expressions or methods: Represented by `{ type, value }` object format, type indicates type, possible values: "JSExpression" (value is expression string) or "JSFunction" (value is function body string). All dynamic content (involving this.xxx) needs `{ type, value }` format (such as condition, binding variables to component properties, binding events, etc.). Example 1, bind state to props.text: `"text": { "type": "JSExpression", "value": "this.state.text"}`, Example 2, bind method to click event: `"onClick": { "type": "JSExpression", "value": "this.handleButtonClick"}` +- Event binding: Used to bind handler methods to component events, use dynamic expression `{ "type": "JSExpression", "value": "xxx" }` to bind, similar to event binding in Vue. Events automatically pass event parameter (first parameter), additional parameters passed via params(string[]) (second and subsequent parameters), e.g. `"onClick": { "type": "JSExpression", "value": "this.handleButtonClick"}`, equivalent to `@click="(...eventArgs) => handleButtonClick(eventArgs)"` in Vue. Example: `"onClick": { "type": "JSExpression", "value": "this.handleButtonClick", "params": ["item", "'pure string param'"]}`, equivalent to `@click="(...eventArgs) => sendMessage(eventArgs, item, 'pure string param')"` in Vue +- Two-way binding: Used for input and other form scenarios, similar to two-way binding in Vue. Two-way binding enabled via model field (`model?: true | { prop: string }`). All form-type components with modelValue property support two-way binding and should prioritize it. Example 1: `{"value":{"type":"JSExpression","value":"item.selected", "model": true }}` equivalent to `v-model="item.selected"` in Vue. Example 2: `{"value":{"type":"JSExpression","value":"item.selected","model":{"prop":"visible"}}}` equivalent to `v-model:visible="item.selected"` in Vue +- Dynamic class: When using dynamic class, set className type to JSExpression in props, set className value to dynamic class expression. Example: `{"className":{"type":"JSExpression","value":"['header-layout-icon left', {'active': this.state.fixedActive}]"}}` +- Loop: When rendering multiple identical components, use loop feature, similar to v-for in Vue. loop property is the array to iterate, loopArgs property represents each array item, key property can represent each item's index. Example: `{ "componentName": "div", "props": { "key": { "type": "JSExpression", "value": "index" } }, "children": [ { "componentName": "Text", "props": { "style": "display: inline-block;", "text": { "type": "JSExpression", "value": "message.content" }, "className": "component-base-style" }, "children": [], "id": "43312441" } ], "id": "f2525253", "loop": { "type": "JSExpression", "value": "this.state.messages" }, "loopArgs": ["message", "index"] }` +- Reactive watch: Used to watch variable values, similar to watch in Vue. When using watch, need to combine with setup passing watch. Example: `{ "lifeCycles": { "setup": { "type": "JSFunction", "value": "function setup({ props, state, watch }) {\n watch(() => props.list, (list) => { cloumnsVisibledSetting(list) }, { deep: true } )\n}" } } }` +- Method invocation: When calling another method within a method, use `this.methodName()` invocation. Example: `{ "methods": { "handleBtnClick": { "type": "JSFunction", "value": "function handleBtnClick(event) {\n console.log('button click')\n this.test('test')\n}\n" }, "test": { "type": "JSFunction", "value": "function test(name) {\n console.log('test', name)\n}\n" } } }` + +### 3.3 Component Rules + +Components (componentName) can use low-code platform components (TinyVue component library) or native HTML components (div, img, h1, a, span, etc.). All available low-code platform components are as follows: +{{COMPONENTS_LIST}} + +Note: +- All form components with the `modelValue` property support two-way binding. This approach should be prioritized. If two-way binding is used, there is no need to redundantly bind the `onChange` or `onUpdate:modelValue` events. + +----- + +## 4. Examples + +{{EXAMPLES_SECTION}} + +----- + +## 5. Current Context + +**[Current Page Schema]** +{{CURRENT_PAGE_SCHEMA}} + +**[Reference Knowledge]** +{{REFERENCE_KNOWLEDGE}} + +**[Image Assets]** +Use the following image resources on demand: +{{IMAGE_ASSETS}} diff --git a/packages/plugins/robot/src/system-prompt.md b/packages/plugins/robot/src/prompts/templates/chat-prompt.md similarity index 100% rename from packages/plugins/robot/src/system-prompt.md rename to packages/plugins/robot/src/prompts/templates/chat-prompt.md diff --git a/packages/plugins/robot/src/mcp/types.ts b/packages/plugins/robot/src/types/chat.types.ts similarity index 64% rename from packages/plugins/robot/src/mcp/types.ts rename to packages/plugins/robot/src/types/chat.types.ts index 781523f3e0..eb8dd0a23b 100644 --- a/packages/plugins/robot/src/mcp/types.ts +++ b/packages/plugins/robot/src/types/chat.types.ts @@ -1,9 +1,11 @@ import type { BubbleContentItem } from '@opentiny/tiny-robot' +import type { ResponseToolCall } from './mcp.types' export interface RequestOptions { url?: string model?: string headers?: Record + baseUrl?: string } export interface RequestTool { @@ -35,7 +37,7 @@ export interface LLMMessage { export interface RobotMessage { role: string - content: string + content: string | BubbleContentItem[] renderContent?: Array [prop: string]: unknown } @@ -48,44 +50,13 @@ export interface LLMRequestBody { tools?: RequestTool[] } -export interface ReponseToolCall { - id: string - function: { - name: string - arguments: string - } -} - export interface LLMResponse { choices: Array<{ message: { role?: string content: string - tool_calls?: Array + tool_calls?: Array [prop: string]: unknown } }> } - -export interface McpTool { - name: string - title?: string - description: string - inputSchema?: { - type: 'object' - properties: Record< - string, - { - type: string - description: string - [prop: string]: unknown - } - > - [prop: string]: unknown - } - [prop: string]: unknown -} - -export interface McpListToolsResponse { - tools: Array -} diff --git a/packages/plugins/robot/assets/test.png b/packages/plugins/robot/src/types/common.types.ts similarity index 100% rename from packages/plugins/robot/assets/test.png rename to packages/plugins/robot/src/types/common.types.ts diff --git a/packages/plugins/robot/src/types/index.ts b/packages/plugins/robot/src/types/index.ts new file mode 100644 index 0000000000..861b4d920d --- /dev/null +++ b/packages/plugins/robot/src/types/index.ts @@ -0,0 +1,2 @@ +export * from './mcp.types' +export * from './chat.types' diff --git a/packages/plugins/robot/src/types/mcp.types.ts b/packages/plugins/robot/src/types/mcp.types.ts new file mode 100644 index 0000000000..571d3c2b51 --- /dev/null +++ b/packages/plugins/robot/src/types/mcp.types.ts @@ -0,0 +1,38 @@ +export interface ResponseToolCall { + id: string + function: { + name: string + arguments: string + } +} + +export interface McpTool { + name: string + description: string + inputSchema?: { + type: 'object' + properties: Record< + string, + { + type: string + description: string + [prop: string]: unknown + } + > + [prop: string]: unknown + } + [prop: string]: unknown +} + +export interface McpListToolsResponse { + tools: Array +} + +export interface NextServerInfoResult { + device: { + referer?: string + ip?: string + [prop: string]: unknown + } + type: string +} diff --git a/packages/plugins/robot/src/utils/chat.utils.ts b/packages/plugins/robot/src/utils/chat.utils.ts new file mode 100644 index 0000000000..6a7cd768df --- /dev/null +++ b/packages/plugins/robot/src/utils/chat.utils.ts @@ -0,0 +1,105 @@ +import { toRaw } from 'vue' +import { META_SERVICE, getMetaApi } from '@opentiny/tiny-engine-meta-register' +import type { StreamHandler } from '@opentiny/tiny-robot-kit' +import type { LLMMessage, LLMRequestBody, RequestOptions, RequestTool } from '../types' + +// 格式化LLM输入messages消息 +export const formatMessages = (messages: LLMMessage[]) => { + const validMessageFilter = (message: LLMMessage) => message.content || message.tool_calls || message.tool_call_id + return toRaw(messages) + .filter(validMessageFilter) + .map((message) => ({ + role: message.role, + content: message.content, + ...(message.tool_calls ? { tool_calls: message.tool_calls } : {}), + ...(message.tool_call_id ? { tool_call_id: message.tool_call_id } : {}) + })) +} + +export const serializeError = (err: unknown): string => { + if (err instanceof Error) { + return JSON.stringify({ name: err.name, message: err.message }) + } + if (typeof err === 'string') return err + try { + return JSON.stringify(err) + } catch { + return String(err) + } +} + +/** + * 合并字符串字段。如果值是对象,则递归合并字符串字段 + * @param target 目标对象 + * @param source 源对象 + * @returns 合并后的对象 + */ +export const mergeStringFields = (target: Record, source: Record) => { + for (const [key, value] of Object.entries(source)) { + const targetValue = target[key] + + if (targetValue) { + if (typeof targetValue === 'string' && typeof value === 'string') { + // 都是字符串,直接拼接 + target[key] = targetValue + value + } else if (targetValue && typeof targetValue === 'object' && value && typeof value === 'object') { + // 都是对象,递归合并 + target[key] = mergeStringFields(targetValue, value) + } + } else { + // 不存在,直接赋值 + target[key] = value + } + } + + return target +} + +export const fetchLLM = async (messages: LLMMessage[], tools: RequestTool[], options: RequestOptions = {}) => { + const bodyObj: LLMRequestBody = { + baseUrl: options.baseUrl, + model: options?.model || 'deepseek-chat', + stream: false, + messages: toRaw(messages) + } + if (tools.length > 0) { + bodyObj.tools = toRaw(tools) + } + return getMetaApi(META_SERVICE.Http).post(options?.url || '/app-center/api/chat/completions', bodyObj, { + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }) +} + +export const processSSEStream = (data: string, handler: StreamHandler) => { + let finishReason: string | undefined + let latestFinishReason: string | undefined + const lines = data.split('\n\n') + lines.pop() + + for (const line of lines) { + if (line.trim() === '') continue + if (line.trim() === 'data: [DONE]') { + if (latestFinishReason) { + finishReason = latestFinishReason + } + handler.onDone(finishReason) + continue + } + + try { + // 解析SSE消息 + const dataMatch = line.match(/^data: (.+)$/m) + if (!dataMatch) continue + + const data = JSON.parse(dataMatch[1]) + handler.onData(data) + latestFinishReason = data.choices?.[0]?.finish_reason || undefined + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error parsing SSE message:', error, line) + } + } +} diff --git a/packages/plugins/robot/src/utils/index.ts b/packages/plugins/robot/src/utils/index.ts new file mode 100644 index 0000000000..9763a670f3 --- /dev/null +++ b/packages/plugins/robot/src/utils/index.ts @@ -0,0 +1 @@ +export * from './chat.utils' diff --git a/packages/plugins/robot/test/test.ts b/packages/plugins/robot/test/test.ts deleted file mode 100644 index e69de29bb2..0000000000