Skip to content

Commit da1c7d6

Browse files
authored
feat: Add AI streaming data real-time rendering (opentiny#1614)
1 parent f9ea40c commit da1c7d6

3 files changed

Lines changed: 62 additions & 47 deletions

File tree

packages/plugins/robot/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,16 @@
2727
"dependencies": {
2828
"@opentiny/tiny-engine-meta-register": "workspace:*",
2929
"@opentiny/tiny-engine-common": "workspace:*",
30+
"@opentiny/tiny-engine-utils": "workspace:*",
3031
"@opentiny/tiny-robot": "0.3.0-rc.0",
3132
"@opentiny/tiny-robot-kit": "0.3.0-rc.0",
3233
"@opentiny/tiny-robot-svgs": "0.3.0-rc.0",
3334
"@opentiny/tiny-schema-renderer": "1.0.0-beta.6",
3435
"fast-json-patch": "~3.1.1",
3536
"dompurify": "^3.0.1",
3637
"highlight.js": "^11.11.1",
37-
"markdown-it": "^14.1.0"
38+
"markdown-it": "^14.1.0",
39+
"jsonrepair": "3.13.0"
3840
},
3941
"devDependencies": {
4042
"@opentiny/tiny-engine-vite-plugin-meta-comments": "workspace:*",

packages/plugins/robot/src/Main.vue

Lines changed: 43 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -130,13 +130,13 @@ import {
130130
TrPrompts,
131131
TrBubbleList,
132132
TrSender,
133-
TrFeedback,
134133
TrAttachments,
135134
TrBubbleProvider
136135
} from '@opentiny/tiny-robot'
137136
import type { BubbleRoleConfig, PromptProps } from '@opentiny/tiny-robot'
138137
import { IconNewSession } from '@opentiny/tiny-robot-svgs'
139138
import SchemaRenderer from '@opentiny/tiny-schema-renderer'
139+
import { utils } from '@opentiny/tiny-engine-utils'
140140
import RobotSettingPopover from './RobotSettingPopover.vue'
141141
import {
142142
getBlockContent,
@@ -151,7 +151,7 @@ import {
151151
} from './js/robotSetting'
152152
import { PROMPTS } from './js/prompts'
153153
import * as jsonpatch from 'fast-json-patch'
154-
import { chatStream } from './js/utils'
154+
import { chatStream, checkComponentNameExists } from './js/utils'
155155
import McpServer from './mcp/McpServer.vue'
156156
import useMcpServer from './mcp/useMcp'
157157
import MarkdownRenderer from './mcp/MarkdownRenderer.vue'
@@ -162,6 +162,7 @@ import RobotTypeSelect from './RobotTypeSelect.vue'
162162
import McpIconComponent from './icon-prompt/mcp-icon.vue'
163163
import PageIconComponent from './icon-prompt/page-icon.vue'
164164
import StudyIconComponent from './icon-prompt/study-icon.vue'
165+
import { jsonrepair } from 'jsonrepair'
165166
166167
export default {
167168
components: {
@@ -208,10 +209,11 @@ export default {
208209
const showPreview = ref(false)
209210
const singleAttachmentItems = ref([])
210211
const imageUrl = ref('')
211-
const MESSAGE_TIP = '已生成新的页面效果,请点击下方按钮应用schema'
212+
const MESSAGE_TIP = '已生成新的页面效果'
212213
const aiType = ref(TALK_TYPE)
213214
const chatContainerRef = ref(null)
214215
const showTeleport = ref(false)
216+
const { deepClone, string2Obj, reactiveObj2String: obj2String } = utils
215217
const sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay))
216218
watchEffect(() => {
217219
avatarUrl.value = 'img/defaultAvator.png'
@@ -296,19 +298,20 @@ export default {
296298
id
297299
})
298300
299-
const setSchema = () => {
301+
const setSchema = async (schema: any) => {
300302
const value = {
301303
...pageState.pageSchema,
302-
...currentSchema.value,
304+
...schema,
303305
componentName: pageState.pageSchema.componentName
304306
}
305307
importSchema(value)
306308
setSaved(false)
307309
showPreview.value = false
310+
await nextTick()
308311
}
309312
310313
// 处理响应
311-
const handleResponse = ({ id, chatMessage }: { id: string; chatMessage: any }) => {
314+
const handleResponse = ({ id, chatMessage }: { id: string; chatMessage: any }, currentJson) => {
312315
try {
313316
if (aiType.value === BUILD_TYPE) {
314317
const regex = /```json([\s\S]*?)```/
@@ -317,7 +320,7 @@ export default {
317320
if (match && match[1] && JSON.parse(match[1]) && isValidFastJsonPatch(JSON.parse(match[1]))) {
318321
const newValue = JSON.parse(match[1])
319322
// 使用 applyPatch 修改 Schema
320-
const result = newValue.reduce(jsonpatch.applyReducer, pageState.pageSchema)
323+
const result = newValue.reduce(jsonpatch.applyReducer, currentJson)
321324
322325
sessionProcess.messages.push(getAiRespMessage(JSON.stringify(result, null, 2), chatMessage.role))
323326
sessionProcess.displayMessages.push(getAiDisplayMessage(MESSAGE_TIP, chatMessage.role, result, id))
@@ -385,6 +388,9 @@ export default {
385388
386389
let streamContent = ''
387390
const chatId = Date.now().toString()
391+
const currentJson = deepClone(pageState.pageSchema)
392+
let lastExecutionTime = 0
393+
const throttleDelay = 3000
388394
await chatStream(
389395
{
390396
requestUrl: '/app-center/api/ai/chat',
@@ -402,6 +408,24 @@ export default {
402408
}
403409
streamContent += choice.delta.content
404410
messages.value[messages.value.length - 1].content += choice.delta.content
411+
const currentTime = Date.now()
412+
if (currentTime - lastExecutionTime > throttleDelay) {
413+
try {
414+
const repaired = jsonrepair(streamContent)
415+
const parsedJson = JSON.parse(repaired)
416+
const result = parsedJson.reduce((acc, patch) => {
417+
return jsonpatch.applyPatch(acc, [patch], false, false).newDocument
418+
}, currentJson)
419+
const editorValue = string2Obj(obj2String(result))
420+
421+
if (editorValue && checkComponentNameExists(result)) {
422+
setSchema(result)
423+
}
424+
} catch (error) {
425+
// error
426+
}
427+
lastExecutionTime = currentTime
428+
}
405429
}
406430
},
407431
onError: (error) => {
@@ -413,14 +437,17 @@ export default {
413437
console.error('Stream error:', error)
414438
},
415439
onDone: () => {
416-
handleResponse({
417-
id: chatId,
418-
chatMessage: {
419-
role: 'assistant',
420-
content: streamContent || '没有返回内容',
421-
name: 'AI'
422-
}
423-
})
440+
handleResponse(
441+
{
442+
id: chatId,
443+
chatMessage: {
444+
role: 'assistant',
445+
content: streamContent || '没有返回内容',
446+
name: 'AI'
447+
}
448+
},
449+
currentJson
450+
)
424451
}
425452
},
426453
{
@@ -645,21 +672,13 @@ export default {
645672
sendContent(item.description, true)
646673
}
647674
648-
const getItemSchema = (item) => {
649-
const targetMessage = messages.value.find((message) => message.id && message.id === item.id)
650-
651-
return targetMessage
652-
}
653-
654675
// Icon
655676
const getSvgIcon = (name: string, style?: CSSProperties) => {
656677
return h(resolveComponent('svg-icon'), { name, style: { fontSize: '32px', ...style } })
657678
}
658679
const aiAvatar = getSvgIcon('AI')
659680
const userAvatar = getSvgIcon('user-head', { color: '#dfe1e6' })
660681
const welcomeIcon = getSvgIcon('AI', { fontSize: '48px' })
661-
const saveIcon = getSvgIcon('save', { fontSize: '20px' })
662-
const previewIcon = getSvgIcon('preview', { fontSize: '20px' })
663682
664683
// 处理文件选择事件
665684
const handleSingleFilesSelected = (files: FileList | null, retry = false) => {
@@ -742,29 +761,7 @@ export default {
742761
avatar: aiAvatar,
743762
maxWidth: '90%',
744763
contentRenderer: MarkdownRenderer,
745-
customContentField: 'renderContent',
746-
slots: {
747-
footer: ({ bubbleProps }) => {
748-
return h(TrFeedback, {
749-
style: {
750-
display: getItemSchema(bubbleProps)?.schema && aiType.value === BUILD_TYPE ? 'block' : 'none'
751-
},
752-
actions: [
753-
{ name: 'run', label: '应用', icon: saveIcon },
754-
{ name: 'preview', label: '预览', icon: previewIcon }
755-
],
756-
onAction(name) {
757-
currentSchema.value = getItemSchema(bubbleProps)?.schema || {}
758-
if (name === 'preview') {
759-
showPreview.value = true
760-
}
761-
if (name === 'run') {
762-
setSchema()
763-
}
764-
}
765-
})
766-
}
767-
}
764+
customContentField: 'renderContent'
768765
},
769766
user: { placement: 'end', avatar: userAvatar, maxWidth: '90%', contentRenderer: MarkdownRenderer },
770767
system: { hidden: true }

packages/plugins/robot/src/js/utils.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,19 @@ export const chatStream = async (requestOpts: any, handler: StreamHandler, heade
2626
logger.error('Error in chatStream:', error)
2727
}
2828
}
29+
30+
export const checkComponentNameExists = (data: any) => {
31+
if (!data.componentName) {
32+
return false
33+
}
34+
35+
if (data.children && Array.isArray(data.children)) {
36+
for (const child of data.children) {
37+
if (!checkComponentNameExists(child)) {
38+
return false
39+
}
40+
}
41+
}
42+
43+
return true
44+
}

0 commit comments

Comments
 (0)