feat(site): add the tiny-robot drawer to the official website. (#3467)

* feat(site): 添加tiny-robot的初始代码

* fix(site): fix

* fix(robot): fix

* fix(robot):  添加自定义适配 dify

* fix(site): fix

* fix(site): 在表格中,专门添加智能体文件

* fix(site): ai-agent

* fix(site): fix

* ci(workflow): update branches for auto deployment

* chore: 还原action脚本

* fix(site): fix 检视意见

---------

Co-authored-by: ajaxzheng <894103554@qq.com>
This commit is contained in:
申君健 2025-06-03 10:34:38 +08:00 committed by GitHub
parent fb9222f35b
commit cfe8e981e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1085 additions and 6 deletions

View File

@ -0,0 +1,100 @@
<template>
<tiny-grid :data="tableData" :edit-config="{ trigger: 'click', mode: 'cell', showStatus: true }">
<tiny-grid-column type="index" width="60"></tiny-grid-column>
<tiny-grid-column type="selection" width="60"></tiny-grid-column>
<tiny-grid-column field="employees" title="员工数"></tiny-grid-column>
<tiny-grid-column field="createdDate" title="创建日期"></tiny-grid-column>
<tiny-grid-column field="city" title="城市"></tiny-grid-column>
<tiny-grid-column
field="boole"
title="布尔值"
align="center"
format-text="boole"
:editor="checkboxEdit"
></tiny-grid-column>
</tiny-grid>
</template>
<script setup lang="jsx">
import { TinyGrid, TinyGridColumn } from '@opentiny/vue'
import { reactive } from 'vue'
const tableData = reactive([
{
id: '1',
name: 'GFD 科技 YX 公司',
city: '福州',
employees: 800,
createdDate: '2014-04-30 00:56:00',
boole: false
},
{
id: '2',
name: 'WWW 科技 YX 公司',
city: '深圳',
employees: 300,
createdDate: '2016-07-08 12:36:22',
boole: true
},
{
id: '3',
name: 'RFV 有限责任公司',
city: '中山',
employees: 1300,
createdDate: '2014-02-14 14:14:14',
boole: false
},
{
id: '4',
name: 'TGB 科技 YX 公司',
city: '龙岩',
employees: 360,
createdDate: '2013-01-13 13:13:13',
boole: true
},
{
id: '5',
name: 'YHN 科技 YX 公司',
city: '韶关',
employees: 810,
createdDate: '2012-12-12 12:12:12',
boole: true
},
{
id: '6',
name: 'WSX 科技 YX 公司',
city: '黄冈',
employees: 800,
createdDate: '2011-11-11 11:11:11',
boole: true
},
{
id: '7',
name: 'KBG 物业 YX 公司',
city: '赤壁',
employees: 400,
createdDate: '2016-04-30 23:56:00',
boole: false
},
{
id: '8',
name: '深圳市福德宝网络技术 YX 公司',
boole: true,
city: '厦门',
createdDate: '2016-06-03 13:53:25',
employees: 540
}
])
function checkboxEdit(h, { row }) {
return (
<input
type="checkbox"
checked={row.boole}
onChange={(event) => {
row.boole = event.target.checked
}}
/>
)
}
</script>

View File

@ -0,0 +1,182 @@
<template>
<tiny-grid
:data="tableData"
:edit-config="{ trigger: 'click', mode: 'cell', showStatus: true }"
:tiny_mcp_config="{
server,
business: {
id: 'company-information',
description: '公司人员信息表'
}
}"
>
<tiny-grid-column type="index" width="60"></tiny-grid-column>
<tiny-grid-column type="selection" width="60"></tiny-grid-column>
<tiny-grid-column field="employees" title="员工数"></tiny-grid-column>
<tiny-grid-column field="createdDate" title="创建日期"></tiny-grid-column>
<tiny-grid-column field="city" title="城市"></tiny-grid-column>
<tiny-grid-column
field="boole"
title="布尔值"
align="center"
format-text="boole"
:editor="checkboxEdit"
></tiny-grid-column>
</tiny-grid>
</template>
<script lang="jsx">
import { TinyGrid, TinyGridColumn } from '@opentiny/vue'
import { createTransportPair, createSseProxy } from '@opentiny/next'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
export default {
components: {
TinyGrid,
TinyGridColumn
},
data() {
const tableData = [
{
id: '1',
name: 'GFD 科技 YX 公司',
city: '福州',
employees: 800,
createdDate: '2014-04-30 00:56:00',
boole: false
},
{
id: '2',
name: 'WWW 科技 YX 公司',
city: '深圳',
employees: 300,
createdDate: '2016-07-08 12:36:22',
boole: true
},
{
id: '3',
name: 'RFV 有限责任公司',
city: '中山',
employees: 1300,
createdDate: '2014-02-14 14:14:14',
boole: false
},
{
id: '4',
name: 'TGB 科技 YX 公司',
city: '龙岩',
employees: 360,
createdDate: '2013-01-13 13:13:13',
boole: true
},
{
id: '5',
name: 'YHN 科技 YX 公司',
city: '韶关',
employees: 810,
createdDate: '2012-12-12 12:12:12',
boole: true
},
{
id: '6',
name: 'WSX 科技 YX 公司',
city: '黄冈',
employees: 800,
createdDate: '2011-11-11 11:11:11',
boole: true
},
{
id: '7',
name: 'KBG 物业 YX 公司',
city: '赤壁',
employees: 400,
createdDate: '2016-04-30 23:56:00',
boole: false
},
{
id: '8',
name: '深圳市福德宝网络技术 YX 公司',
boole: true,
city: '厦门',
createdDate: '2016-06-03 13:53:25',
employees: 540
}
]
return {
server: new McpServer({ name: 'base-config', version: '1.0.0' }, {}),
sessionID: '',
op: {
editConfig: {
trigger: 'click',
mode: 'cell',
showStatus: true
},
columns: [
{
type: 'index',
width: 60
},
{
type: 'selection',
width: 60
},
{
field: 'employees',
title: '员工数'
},
{
field: 'createdDate',
title: '创建日期'
},
{
field: 'city',
title: '城市'
},
{
field: 'boole',
title: '布尔值',
align: 'center',
formatText: 'boole',
editor: this.checkboxEdit
}
],
data: tableData
},
tableData
}
},
methods: {
checkboxEdit(h, { row }) {
return (
<input
type="checkbox"
checked={row.boole}
onChange={(event) => {
row.boole = event.target.checked
}}
/>
)
}
},
async mounted() {
// 1
const [transport, clientTransport] = createTransportPair()
// 2
const client = new Client({ name: 'ai-agent', version: '1.0.0' }, {})
await client.connect(clientTransport)
const { sessionId } = await createSseProxy({
client,
url: 'http://39.108.160.245/sse'
})
this.sessionID = sessionId
window.$sessionId = this.sessionID
// 3
await this.server.connect(transport)
}
}
</script>

View File

@ -0,0 +1,7 @@
---
title: Grid 表格
---
# Grid 表格
<div>表格组件,提供了非常强大数据表格功能,在 Grid 可以展示数据列表,可以对数据列表进行选择、编辑等。</div>

View File

@ -0,0 +1,7 @@
---
title: Grid Table
---
# Grid Table
<div>Table component, which provides powerful data table functions. In Grid, data lists can be displayed, selected, and edited.</div>

View File

@ -0,0 +1,23 @@
export default {
column: '1',
owner: '',
demos: [
{
demoId: 'grid-ai-agent',
name: { 'zh-CN': '表格智能体', 'en-US': 'grid agent' },
desc: {
'zh-CN': `表格智能体是表格组件面向 AI 领域的应用,提供了一个基于表格数据的智能体交互界面。用户可以通过自然语言与表格进行交互,智能体会根据表格内容和用户输入执行操作。<br>
目前表格智能体支持的操作包括<br>
- 查询表格数据用户可以输入自然语言查询表格中的数据智能体会解析查询并返回结果以及根据结果执行后续的操作<br>
- 滚动到指定行用户可以通过自然语言指令将表格滚动到指定位置<br>
- 选中表格数据用户可以通过自然语言指令选中表格数据<br>
- 选中全部数据用户可以通过自然语言指令选中全部数据<br>
通过属性 <code>tiny-mcp-config</code> <code>server</code>`,
'en-US': ``
},
codeFiles: ['ai-agent/basic-usage.vue']
}
],
apis: [{ name: 'grid-ai-agent', 'type': 'component', 'props': [], 'events': [], 'slots': [] }]
}

View File

@ -175,6 +175,7 @@ export const cmpMenus = [
'key': 'cmp-table-components', 'key': 'cmp-table-components',
'children': [ 'children': [
{ 'nameCn': '基本用法', 'name': '', 'key': 'grid' }, { 'nameCn': '基本用法', 'name': '', 'key': 'grid' },
{ 'nameCn': 'AI智能体', 'name': '', 'key': 'grid-ai-agent' },
{ 'nameCn': '序号列', 'name': '', 'key': 'grid-serial-column' }, { 'nameCn': '序号列', 'name': '', 'key': 'grid-serial-column' },
{ 'nameCn': '选中行', 'name': '', 'key': 'grid-operation-column' }, { 'nameCn': '选中行', 'name': '', 'key': 'grid-operation-column' },
{ 'nameCn': '空数据', 'name': '', 'key': 'grid-empty' }, { 'nameCn': '空数据', 'name': '', 'key': 'grid-empty' },

View File

@ -27,6 +27,13 @@
"@docsearch/css": "^3.8.0", "@docsearch/css": "^3.8.0",
"@docsearch/js": "^3.8.0", "@docsearch/js": "^3.8.0",
"@docsearch/react": "npm:@docsearch/css", "@docsearch/react": "npm:@docsearch/css",
"@modelcontextprotocol/sdk": "1.12.1",
"@opentiny/next": "0.1.2",
"@opentiny/tiny-robot": "0.2.0-alpha.7",
"@opentiny/tiny-robot-kit": "0.2.0-alpha.7",
"@opentiny/tiny-robot-svgs": "0.2.0-alpha.7",
"@opentiny/tiny-schema-renderer": "1.0.0-beta.5",
"@opentiny/tiny-vue-mcp": "0.0.1-beta.1",
"@opentiny/utils": "workspace:~", "@opentiny/utils": "workspace:~",
"@opentiny/vue": "workspace:~", "@opentiny/vue": "workspace:~",
"@opentiny/vue-common": "workspace:~", "@opentiny/vue-common": "workspace:~",

View File

@ -1,10 +1,11 @@
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
import { initI18n } from '@opentiny/vue-locale' import { initI18n, t } from '@opentiny/vue-locale'
import { $local } from '../tools' import { $local } from '../tools'
import zh from './zh.json' import zh from './zh.json'
import en from './en.json' import en from './en.json'
import { zhCN, enUS } from '@opentiny/tiny-vue-mcp'
const messages = { enUS: en, zhCN: zh } const messages = { enUS: { ...en, ...enUS }, zhCN: { ...zh, ...zhCN } }
// $local._lang = $local._lang !== 'zhCN' && $local._lang !== 'enUS' ? 'zhCN' : $local._lang // $local._lang = $local._lang !== 'zhCN' && $local._lang !== 'enUS' ? 'zhCN' : $local._lang
$local._lang = 'zhCN' $local._lang = 'zhCN'
const customCreateI18n = ({ locale, messages }) => const customCreateI18n = ({ locale, messages }) =>
@ -26,3 +27,5 @@ const i18nByKey = i18n.global.t
const getWord = (cn, en) => (i18n.global.locale === 'zhCN' ? cn : en) const getWord = (cn, en) => (i18n.global.locale === 'zhCN' ? cn : en)
export { i18n, i18nByKey, getWord } export { i18n, i18nByKey, getWord }
export { t }

View File

@ -2,6 +2,9 @@ import { createHead } from '@vueuse/head'
import { createApp } from 'vue' import { createApp } from 'vue'
import '@unocss/reset/eric-meyer.css' import '@unocss/reset/eric-meyer.css'
// tiny-robot 对话框
import '@opentiny/tiny-robot/dist/style.css'
// markdown文件内代码高亮 // markdown文件内代码高亮
import 'prismjs/themes/prism.css' import 'prismjs/themes/prism.css'
import 'uno.css' import 'uno.css'
@ -16,7 +19,7 @@ import './assets/custom-markdown.css'
import './assets/custom-block.less' import './assets/custom-block.less'
import './assets/md-preview.less' import './assets/md-preview.less'
import { i18n } from './i18n/index' import { i18n, t } from './i18n/index'
import { router } from './router' import { router } from './router'
import App from './App.vue' import App from './App.vue'
import { appData } from './tools' import { appData } from './tools'
@ -33,6 +36,12 @@ import '@docsearch/css'
import { doSearchEverySite } from './tools/docsearch' import { doSearchEverySite } from './tools/docsearch'
import '@opentiny/vue-theme/dark-theme-index.css' import '@opentiny/vue-theme/dark-theme-index.css'
import { registerMcpConfig } from '@opentiny/vue-common'
import { createMcpTools, getTinyVueMcpConfig } from '@opentiny/tiny-vue-mcp'
// 注册TinyVue组件mcp配置
registerMcpConfig(getTinyVueMcpConfig({ t }), createMcpTools)
const envTarget = import.meta.env.VITE_BUILD_TARGET || 'open' const envTarget = import.meta.env.VITE_BUILD_TARGET || 'open'
hljs.registerLanguage('javascript', javascript) hljs.registerLanguage('javascript', javascript)

View File

@ -9,7 +9,13 @@
<slot name="header-right" /> <slot name="header-right" />
</template> </template>
</ComponentHeader> </ComponentHeader>
<div class="docs-content" id="doc-layout-scroller" ref="scrollRef" @scroll="onDocLayoutScroll"> <div
class="docs-content"
:class="{ 'docs-on-robot-show': show }"
id="doc-layout-scroller"
ref="scrollRef"
@scroll="onDocLayoutScroll"
>
<div class="ti-rel cmp-container"> <div class="ti-rel cmp-container">
<div class="flex-horizontal docs-content-main"> <div class="flex-horizontal docs-content-main">
<div class="docs-tabs-wrap"> <div class="docs-tabs-wrap">
@ -84,6 +90,7 @@
</div> </div>
<div id="footer"></div> <div id="footer"></div>
</div> </div>
<robotChat v-if="show"></robotChat>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -101,6 +108,8 @@ import ComponentContributor from './components/contributor.vue'
import ApiDocs from './components/api-docs.vue' import ApiDocs from './components/api-docs.vue'
import useTasksFinish from './composition/useTasksFinish' import useTasksFinish from './composition/useTasksFinish'
import robotChat from './tiny-robot-chat.vue'
const props = defineProps({ loadData: {}, appMode: {}, demoKey: {} }) const props = defineProps({ loadData: {}, appMode: {}, demoKey: {} })
const emit = defineEmits(['single-demo-change', 'load-page']) const emit = defineEmits(['single-demo-change', 'load-page'])
@ -435,9 +444,21 @@ const handleAnchorClick = (e, data) => {
} }
defineExpose({ loadPage }) defineExpose({ loadPage })
const show = ref(false)
onMounted(() => {
// tiny-robot mcp-robot,
const hasRobot = router.currentRoute.value.hash === '#grid-ai-agent'
show.value = !!hasRobot
document.body.classList.toggle('docs-on-robot-show', show.value)
})
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
:global(.docs-on-robot-show .docs-content) {
margin-right: 480px;
}
.docs-content { .docs-content {
flex: 1; flex: 1;
overflow: hidden auto; overflow: hidden auto;

View File

@ -256,6 +256,10 @@ export default defineComponent({
</script> </script>
<style lang="less"> <style lang="less">
.docs-on-robot-show .float-settings {
right: 680px;
}
.float-settings { .float-settings {
position: fixed; position: fixed;
right: 200px; right: 200px;

View File

@ -0,0 +1,62 @@
import type { ChatCompletionRequest } from '@opentiny/tiny-robot-kit'
import type { AIModelConfig } from '@opentiny/tiny-robot-kit'
import type { ChatCompletionResponse } from '@opentiny/tiny-robot-kit'
import type { StreamHandler } from '@opentiny/tiny-robot-kit'
import { BaseModelProvider } from '@opentiny/tiny-robot-kit'
import { handleSSEStream } from './utils.js'
/**
* AIClient的自定义 Dify
*
* const client = new AIClient({
* provider: 'custom',
* providerImplementation: new CustomModelProvider( config )
* });
*/
export class DifyModelProvider extends BaseModelProvider {
constructor(config: AIModelConfig) {
super(config)
}
/** 同步请示不需要实现 */
chat(request: ChatCompletionRequest): Promise<ChatCompletionResponse> {
throw new Error('Method not implemented.')
}
/** 异步流式请求 */
async chatStream(request: ChatCompletionRequest, handler: StreamHandler): Promise<void> {
const { signal } = request
this.validateRequest(request)
try {
// 验证请求的messages属性必须是数组且每个消息必须有role\content属性
const lastMessage = request.messages[request.messages.length - 1].content
// 模拟异步流式响应
const response = await fetch(`${this.config.apiUrl}/chat-messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.config.apiKey}`,
'Accept': 'text/event-stream'
},
body: JSON.stringify({
query: lastMessage,
user: 'user',
response_mode: 'streaming',
inputs: {
sessionId: window.$sessionId
}
})
})
await handleSSEStream(response, handler, signal)
} catch (error) {
if (signal && signal.aborted) {
console.warn('Request was aborted:', error)
} else {
console.error('Error in chatStream:', error)
// handler.onError(handleRequestError(error))
}
}
}
}

View File

@ -0,0 +1,318 @@
import type { AIModelConfig } from '@opentiny/tiny-robot-kit'
import { AIClient, useConversation } from '@opentiny/tiny-robot-kit'
import { IconAi, IconUser } from '@opentiny/tiny-robot-svgs'
import { h, nextTick, onMounted, reactive, ref, toRaw, watch } from 'vue'
import { DifyModelProvider } from './DifyModelProvider.js'
const difyConfig: AIModelConfig = {
provider: 'custom',
apiUrl: 'https://api.dify.ai/v1',
apiKey: 'app-H0VJI4LqZ4KskdcA5a07pjXf'
}
export function useTinyRobot() {
const client = new AIClient({
providerImplementation: new DifyModelProvider(difyConfig),
...difyConfig
})
const fullscreen = ref(false)
const show = ref(true)
const aiAvatar = h(IconAi, { style: { fontSize: '32px' } })
const userAvatar = h(IconUser, { style: { fontSize: '32px' } })
const welcomeIcon = h(IconAi, { style: { fontSize: '48px' } })
const promptItems = [
{
label: '日常助理场景',
description: '今天需要我帮你安排日程,规划旅行,还是起草一封邮件?',
icon: h('span', { style: { fontSize: '18px' } }, '🧠'),
badge: 'NEW'
},
{
label: '学习/知识型场景',
description: '有什么想了解的吗可以是“Vue3 和 React 的区别”!',
icon: h('span', { style: { fontSize: '18px' } }, '🤔')
},
{
label: '创意生成场景',
description: '想写段文案、起个名字,还是来点灵感?',
icon: h('span', { style: { fontSize: '18px' } }, '✨')
}
]
// 指令模板测试数据
const templateSuggestions = [
{
id: 'write',
text: '帮我写作',
value: '帮我写作',
description: '智能写作助手',
template: '帮我撰写 [文章类型] 字的 [主题], 语气类型是 [正式/轻松/专业], 具体内容是 [详细描述]'
},
{
id: 'translate',
text: '翻译',
value: '翻译',
description: '多语言翻译',
template: '请将以下 [中文/英文/法语/德语/日语] 内容翻译成 [目标语言]: [需要翻译的内容]'
},
{
id: 'summarize',
text: '内容总结',
value: '内容总结',
description: '智能总结长文本',
template: '请对以下内容进行 [简要/详细] 总结,约 [字数] 字: [需要总结的内容]'
},
{
id: 'code-review',
text: '代码审查',
value: '代码审查',
description: '代码质量审查',
template:
'请帮我审查以下 [JavaScript/TypeScript/Python/Java/C++/Go] 代码,关注 [性能/安全/可读性/最佳实践] 方面: [代码内容]'
},
{
id: 'email-compose',
text: '写邮件',
value: '写邮件',
description: '邮件草拟',
template: '请帮我起草一封 [正式/非正式] 邮件,发送给 [收件人角色],主题是 [邮件主题],内容是关于 [邮件内容]'
},
{
id: 'data-analysis',
text: '数据分析',
value: '数据分析',
description: '数据分析与报告',
template:
'请分析以下 [销售/用户/流量/金融/健康] 数据,关注 [增长率/分布/趋势/异常/关联性] 指标,生成 [柱状图/折线图/饼图/散点图/热力图] 可视化: [数据内容]'
},
{
id: 'product-design',
text: '产品设计',
value: '产品设计',
description: '产品功能设计',
template:
'请设计一个 [移动应用/网站/小程序/桌面软件/智能硬件] 的 [功能名称] 功能,目标用户是 [用户群体],核心价值是 [功能价值]'
},
{
id: 'meeting-summary',
text: '会议纪要',
value: '会议纪要',
description: '会议记录整理',
template: '请帮我整理一份会议纪要,会议主题是 [会议主题],参会人员有 [参会人员],会议要点包括 [会议要点]'
},
{
id: 'interview-questions',
text: '面试问题',
value: '面试问题',
description: '生成面试问题',
template: '请为 [岗位名称] 岗位,针对 [技能领域] 方向,设计 [3/5/10] 个 [简单/中等/困难] 面试问题'
},
{
id: 'speech-draft',
text: '演讲稿',
value: '演讲稿',
description: '演讲稿撰写',
template:
'请帮我撰写一篇 [开场/主题/致谢/颁奖/毕业] 演讲稿,主题是 [演讲主题],时长约 [5/10/15/30] 分钟,受众是 [目标听众]'
}
]
// 模板分类测试数据
const templateCategories = [
{
id: 'common',
label: '常用指令',
items: templateSuggestions.slice(0, 3)
},
{
id: 'work',
label: '工作助手',
items: templateSuggestions.slice(3, 6)
},
{
id: 'content',
label: '内容创作',
items: templateSuggestions.slice(6)
}
]
const { messageManager, createConversation } = useConversation({ client })
const randomId = () => Math.random().toString(36).substring(2, 15)
const currentMessageId = ref('')
const { messages, messageState, inputMessage, sendMessage, abortRequest } = messageManager
const handlePromptItemClick = (ev, item) => {
sendMessage(item.description)
}
const roles = {
assistant: {
placement: 'start',
avatar: aiAvatar,
maxWidth: '80%'
},
user: {
placement: 'end',
avatar: userAvatar,
maxWidth: '80%'
}
}
const showHistory = ref(false)
const historyData = reactive([])
watch(
() => messages.value[messages.value.length - 1]?.content,
() => {
if (!messages.value.length) {
return
}
if (messages.value.length === 1) {
currentMessageId.value = randomId()
}
const allSessions = historyData.flatMap((item) => item.items)
const currentSession = allSessions.find((item) => item.id === currentMessageId.value)
const data = toRaw(messages.value)
if (!currentSession) {
const today = historyData.find((item) => item.group === '今天')
if (today) {
today.items.unshift({ title: messages.value[0].content, id: currentMessageId.value, data })
} else {
historyData.unshift({
group: '今天',
items: [{ title: messages.value[0].content, id: currentMessageId.value, data }]
})
}
} else {
currentSession.data = data
}
}
)
const handleHistorySelect = (item) => {
currentMessageId.value = item.id
messages.value = item.data
showHistory.value = false
}
// 指令列表
const suggestionItems = templateSuggestions
const categories = templateCategories
const senderRef = ref(null)
const currentTemplate = ref('')
const suggestionOpen = ref(false)
// 设置指令
const handleFillTemplate = (templateText) => {
setTimeout(() => {
currentTemplate.value = templateText
inputMessage.value = ''
// 等待DOM更新后激活第一个字段
setTimeout(() => {
senderRef.value?.activateTemplateFirstField()
}, 100)
}, 300)
}
// 清除当前指令
const clearTemplate = () => {
// 清空指令相关状态
currentTemplate.value = ''
// 确保重新聚焦到输入框
nextTick(() => {
senderRef.value?.focus()
})
}
// 发送消息
const handleSendMessage = () => {
sendMessage(inputMessage.value)
clearTemplate()
}
const handleMessageKeydown = (event, triggerFn, suggestionKeyDown) => {
// 如果指令面板已打开,交给 suggestion 组件处理键盘事件
if (suggestionOpen.value) {
suggestionKeyDown(event)
return
}
// 如果按下斜杠键并且不在指令编辑模式,触发指令面板
if (event.key === '/' && !currentTemplate.value) {
triggerFn({
text: '',
position: 0
})
}
// ESC 键清除当前指令
if (event.key === 'Escape' && currentTemplate.value) {
event.preventDefault()
clearTemplate()
}
}
watch(
() => inputMessage.value,
(value) => {
// 如果指令面板已打开,并且指令为空,关闭指令面板
if (suggestionOpen.value && value === '') {
suggestionOpen.value = false
}
}
)
// 页面加载完成后自动聚焦输入框
onMounted(() => {
setTimeout(() => {
senderRef.value?.focus()
}, 500)
})
return {
client,
fullscreen,
show,
aiAvatar,
userAvatar,
welcomeIcon,
promptItems,
templateSuggestions,
templateCategories,
messageManager,
createConversation,
messages,
messageState,
inputMessage,
sendMessage,
abortRequest,
roles,
showHistory,
historyData,
currentMessageId,
handlePromptItemClick,
handleHistorySelect,
suggestionItems,
categories,
senderRef,
currentTemplate,
suggestionOpen,
handleFillTemplate,
clearTemplate,
handleSendMessage,
handleMessageKeydown
}
}

View File

@ -0,0 +1,163 @@
/**
*
*
*/
import type { ChatMessage, ChatCompletionResponse, StreamHandler } from '@opentiny/tiny-robot-kit'
/**
* SSE流式响应
* @param response fetch响应对象
* @param handler
*/
export async function handleSSEStream(response: Response, handler: StreamHandler, signal?: AbortSignal): Promise<void> {
// 获取ReadableStream
const reader = response.body?.getReader()
if (!reader) {
throw new Error('Response body is null')
}
// 处理流式数据
const decoder = new TextDecoder()
let buffer = ''
if (signal) {
signal.addEventListener(
'abort',
() => {
reader.cancel().catch((err) => console.error('Error cancelling reader:', err))
},
{ once: true }
)
}
try {
while (true) {
if (signal?.aborted) {
await reader.cancel()
break
}
const { done, value } = await reader.read()
if (done) break
// 解码二进制数据
const chunk = decoder.decode(value, { stream: true })
buffer += chunk
// 处理完整的SSE消息
const lines = buffer.split('\n\n')
buffer = lines.pop() || ''
let messageIndex = 0
for (const line of lines) {
if (line.trim() === '') continue
if (line.trim() === 'data: [DONE]') {
handler.onDone()
continue
}
try {
// 解析SSE消息
const dataMatch = line.match(/^data: (.+)$/m)
if (!dataMatch) continue
const data = JSON.parse(dataMatch[1])
if (data?.event === 'message' && data?.answer) {
handler.onData({
id: data.id,
created: data.created_at,
choices: [
{
index: messageIndex++,
delta: {
role: 'assistant',
content: data.answer
},
finish_reason: null
}
],
object: '',
model: ''
})
}
if (data?.event === 'message_end') {
handler.onData({
id: data.id,
created: data.created_at,
choices: [
{
index: messageIndex++,
delta: {
role: 'assistant',
content: ''
},
finish_reason: 'stop'
}
],
object: '',
model: ''
})
}
// handler.onData(data)
} catch (error) {
console.error('Error parsing SSE message:', error)
}
}
}
if (buffer.trim() === 'data: [DONE]' || signal?.aborted) {
handler.onDone()
}
} catch (error) {
if (signal?.aborted) return
throw error
}
}
/**
*
* ChatMessage格式
* @param messages
* @returns
*/
export function formatMessages(messages: Array<ChatMessage | string>): ChatMessage[] {
return messages.map((msg) => {
// 如果已经是标准格式,直接返回
if (typeof msg === 'object' && 'role' in msg && 'content' in msg) {
return {
role: msg.role,
content: String(msg.content),
...(msg.name ? { name: msg.name } : {})
}
}
// 如果是字符串,默认为用户消息
if (typeof msg === 'string') {
return {
role: 'user',
content: msg
}
}
// 其他情况,尝试转换为字符串
return {
role: 'user',
content: String(msg)
}
})
}
/**
*
* @param response
* @returns
*/
export function extractTextFromResponse(response: ChatCompletionResponse): string {
if (!response.choices || !response.choices.length) {
return ''
}
return response.choices[0].message?.content || ''
}

View File

@ -0,0 +1,172 @@
<template>
<!-- mcp-robot弹窗 -->
<tr-container v-model:show="show" v-model:fullscreen="fullscreen">
<template #operations>
<tr-icon-button :icon="IconNewSession" size="28" svgSize="20" @click="createConversation()" />
<span style="display: inline-flex; line-height: 0; position: relative">
<tr-icon-button :icon="IconHistory" size="28" svgSize="20" @click="showHistory = true" />
<tr-history
v-show="showHistory"
class="tr-history-demo"
tab-title="历史对话"
:selected="currentMessageId"
:search-bar="true"
:data="historyData"
@close="showHistory = false"
@item-click="handleHistorySelect"
></tr-history>
</span>
</template>
<div v-if="messages.length === 0">
<tr-welcome title="盘古助手" description="您好,我是盘古助手,您专属的华为云专家" :icon="welcomeIcon">
<template #footer>
<div class="welcome-footer">
<span>根据相关法律法规要求您需要先 <a>登录</a>若没有帐号您可前往 <a>注册</a></span>
</div>
</template>
</tr-welcome>
<tr-prompts
:items="promptItems"
:wrap="true"
item-class="prompt-item"
class="tiny-prompts"
@item-click="handlePromptItemClick"
></tr-prompts>
</div>
<tr-bubble-list v-else :items="messages" :roles="roles" auto-scroll type="markdown"></tr-bubble-list>
<template #footer>
<div class="chat-input">
<tr-suggestion
v-model:open="suggestionOpen"
:items="suggestionItems"
:categories="categories"
@fill-template="handleFillTemplate"
:maxVisibleItems="5"
>
<template #trigger="{ onKeyDown, onTrigger }">
<tr-sender
ref="senderRef"
mode="single"
v-model="inputMessage"
:placeholder="GeneratingStatus.includes(messageState.status) ? '正在思考中...' : '请输入您的问题'"
:clearable="true"
:loading="GeneratingStatus.includes(messageState.status)"
:showWordLimit="true"
:maxLength="1000"
:template="currentTemplate"
@submit="handleSendMessage"
@cancel="abortRequest"
@keydown="handleMessageKeydown($event, onTrigger, onKeyDown)"
@reset-template="clearTemplate"
></tr-sender>
</template>
</tr-suggestion>
</div>
</template>
</tr-container>
</template>
<script setup lang="ts">
import {
TrBubbleList,
TrContainer,
TrHistory,
TrIconButton,
TrPrompts,
TrSender,
TrSuggestion,
TrWelcome
} from '@opentiny/tiny-robot'
import { GeneratingStatus } from '@opentiny/tiny-robot-kit'
import { IconHistory, IconNewSession } from '@opentiny/tiny-robot-svgs'
import { useTinyRobot } from './composition/useTinyRobot'
const {
client,
fullscreen,
show,
aiAvatar,
userAvatar,
welcomeIcon,
promptItems,
templateSuggestions,
templateCategories,
messageManager,
createConversation,
messages,
messageState,
inputMessage,
sendMessage,
abortRequest,
roles,
showHistory,
historyData,
currentMessageId,
handlePromptItemClick,
handleHistorySelect,
suggestionItems,
categories,
senderRef,
currentTemplate,
suggestionOpen,
handleFillTemplate,
clearTemplate,
handleSendMessage,
handleMessageKeydown
} = useTinyRobot()
</script>
<style scoped lang="less">
.chat-input {
margin-top: 8px;
padding: 10px 15px;
}
.tiny-container {
top: 64px;
container-type: inline-size;
:deep(.tr-welcome__title-wrapper) {
display: flex;
align-items: center;
justify-content: center;
}
}
.welcome-footer {
margin-top: 12px;
color: rgb(128, 128, 128);
font-size: 12px;
line-height: 20px;
}
.tiny-prompts {
padding: 16px 24px;
:deep(.prompt-item) {
width: 100%;
box-sizing: border-box;
@container (width >=64rem) {
width: calc(50% - 8px);
}
.tr-prompt__content-label {
font-size: 14px;
line-height: 24px;
}
}
}
.tr-history-demo {
position: absolute;
right: 100%;
top: 100%;
z-index: 100;
width: 300px;
height: 600px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
}
</style>