chore: offline temporary mcp function demonstration demo (#3553)

* chore: 下线临时mcp功能演示demo

* chore: 添加 @opentiny/tiny-vue-mcp 依赖版本 ^0.0.2
This commit is contained in:
ajaxzheng 2025-07-07 16:24:58 +08:00 committed by GitHub
parent bfce947877
commit b963e65b46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 8 additions and 1871 deletions

View File

@ -1,17 +1,6 @@
<template> <template>
<div> <div>
<tiny-grid <tiny-grid :data="tableData" :edit-config="{ trigger: 'click', mode: 'cell', showStatus: true }" height="420px">
:data="tableData"
:edit-config="{ trigger: 'click', mode: 'cell', showStatus: true }"
height="420px"
:tiny_mcp_config="{
server,
business: {
id: 'company-information',
description: '公司人员信息表'
}
}"
>
<tiny-grid-column type="index" width="60"></tiny-grid-column> <tiny-grid-column type="index" width="60"></tiny-grid-column>
<tiny-grid-column type="selection" width="60"></tiny-grid-column> <tiny-grid-column type="selection" width="60"></tiny-grid-column>
<tiny-grid-column field="company" title="公司名称"></tiny-grid-column> <tiny-grid-column field="company" title="公司名称"></tiny-grid-column>
@ -25,7 +14,6 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { TinyGrid, TinyGridColumn } from '@opentiny/vue' import { TinyGrid, TinyGridColumn } from '@opentiny/vue'
import { useNextServer } from '@opentiny/next-vue'
// //
const _table = [ const _table = [
@ -48,8 +36,4 @@ const _table = [
] ]
const tableData = ref(_table) const tableData = ref(_table)
const { server } = useNextServer({
serverInfo: { name: 'company-information', version: '1.0.0' }
})
</script> </script>

View File

@ -27,10 +27,6 @@
"@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",
"@opentiny/next-vue": "^0.0.1",
"@opentiny/tiny-robot": "0.2.1",
"@opentiny/tiny-robot-kit": "0.2.1",
"@opentiny/tiny-robot-svgs": "0.2.1",
"@opentiny/tiny-vue-mcp": "^0.0.2", "@opentiny/tiny-vue-mcp": "^0.0.2",
"@opentiny/utils": "workspace:~", "@opentiny/utils": "workspace:~",
"@opentiny/vue": "workspace:~", "@opentiny/vue": "workspace:~",

View File

@ -11,13 +11,11 @@
</template> </template>
<script> <script>
import { defineComponent, onMounted, provide, ref, watch } from 'vue' import { defineComponent, onMounted, provide, ref } from 'vue'
import { ConfigProvider, Modal } from '@opentiny/vue' import { ConfigProvider, Modal } from '@opentiny/vue'
import { iconClose } from '@opentiny/vue-icon' import { iconClose } from '@opentiny/vue-icon'
import { appData } from './tools' import { appData } from './tools'
import useTheme from './tools/useTheme' import useTheme from './tools/useTheme'
import { useNextClient } from '@opentiny/next-vue'
import { globalConversation, $session } from './composable/utils'
export default defineComponent({ export default defineComponent({
name: 'AppVue', name: 'AppVue',
@ -31,21 +29,6 @@ export default defineComponent({
const previewUrl = ref(import.meta.env.VITE_PLAYGROUND_URL) const previewUrl = ref(import.meta.env.VITE_PLAYGROUND_URL)
const modalSHow = ref(false) const modalSHow = ref(false)
const { sessionId } = useNextClient({
clientInfo: { name: 'tiny-vue-website', version: '1.0.0' },
proxyOptions: { url: 'https://agent.icjs.ink/sse', token: '', sessionId: $session.sessionId }
})
watch(
() => sessionId.value,
(newVal) => {
if (newVal) {
$session.sessionId = newVal
globalConversation.sessionId = newVal
}
}
)
onMounted(() => { onMounted(() => {
// header // header
const common = new window.TDCommon(['#header'], { const common = new window.TDCommon(['#header'], {

View File

@ -1,117 +0,0 @@
<template>
<div class="message-card" :class="role">
<div class="avatar">
<component :is="avatarIcon" class="avatar-icon" />
</div>
<div class="content">
<div class="role-name">{{ roleName }}</div>
<div class="message">{{ message }}</div>
<div class="time">{{ formatTime }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { IconAi, IconUser } from '@opentiny/tiny-robot-svgs'
interface Props {
role: 'user' | 'assistant'
message: string
timestamp: number
}
const props = defineProps<Props>()
const roleName = computed(() => (props.role === 'assistant' ? '智能助手' : '我'))
const avatarIcon = computed(() => (props.role === 'assistant' ? IconAi : IconUser))
const formatTime = computed(() => {
const date = new Date(props.timestamp)
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false
})
})
</script>
<style scoped lang="less">
.message-card {
display: flex;
margin: 16px;
gap: 12px;
max-width: 80%;
&.assistant {
margin-right: auto;
.content {
background-color: #f0f2f5;
}
.avatar-icon {
color: #2080f0;
}
}
&.user {
margin-left: auto;
flex-direction: row-reverse;
.content {
background-color: #e8f5e9;
}
.avatar-icon {
color: #18a058;
}
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f7fa;
.avatar-icon {
width: 24px;
height: 24px;
}
}
.content {
padding: 12px;
border-radius: 12px;
position: relative;
min-width: 120px;
max-width: calc(100% - 60px);
.role-name {
font-size: 14px;
color: #666;
margin-bottom: 4px;
}
.message {
font-size: 16px;
line-height: 1.5;
word-break: break-word;
white-space: pre-wrap;
}
.time {
font-size: 12px;
color: #999;
margin-top: 4px;
text-align: right;
}
}
}
</style>

View File

@ -60,13 +60,6 @@
</div> </div>
<template #reference> <template #reference>
<div> <div>
<div
v-if="appData.hasFloatRobot"
class="settings-btn style-settings-btn"
@click="appData.showTinyRobot = true"
>
<IconAi class="settings-icon style-settings-icon"></IconAi>
</div>
<div <div
class="settings-btn style-settings-btn" class="settings-btn style-settings-btn"
@click="demoStyleVisible = !demoStyleVisible" @click="demoStyleVisible = !demoStyleVisible"
@ -104,7 +97,6 @@ import useTheme from '@/tools/useTheme'
import { appData } from '@/tools/appData.js' import { appData } from '@/tools/appData.js'
import { router } from '@/router' import { router } from '@/router'
import useStyleSettings from '@/tools/useStyleSettings' import useStyleSettings from '@/tools/useStyleSettings'
import { IconAi } from '@opentiny/tiny-robot-svgs'
// import ThemeSettingsIcon from '@/assets/images/theme-settings.svg' // import ThemeSettingsIcon from '@/assets/images/theme-settings.svg'
import StyleSettingsIcon from '@/assets/images/style-settings.svg' import StyleSettingsIcon from '@/assets/images/style-settings.svg'
@ -117,7 +109,6 @@ export default defineComponent({
TinyRadioGroup: RadioGroup, TinyRadioGroup: RadioGroup,
IconUpWard: iconUpWard(), IconUpWard: iconUpWard(),
TinyPopover: Popover, TinyPopover: Popover,
IconAi,
// ThemeSettingsIcon, // ThemeSettingsIcon,
StyleSettingsIcon StyleSettingsIcon
}, },
@ -269,10 +260,6 @@ 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

@ -1,128 +0,0 @@
<template>
<!-- mcp-robot弹窗 -->
<tr-container v-model:show="appData.showTinyRobot" v-model:fullscreen="fullscreen">
<div v-if="messages.length === 0">
<tr-welcome title="智能助手" description="您好我是OpenTiny AI智能助手" :icon="welcomeIcon">
<template #footer>
<div class="welcome-footer"></div>
</template>
</tr-welcome>
<tr-prompts
:items="customPromptItems"
: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></tr-bubble-list>
<template #footer>
<div class="chat-input">
<TrSuggestionPills :items="customSuggestionPillItems" @item-click="handleSuggestionPillItemClick" /><br />
<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>
</div>
</template>
</tr-container>
</template>
<script setup lang="ts">
import { TrBubbleList, TrContainer, TrPrompts, TrSender, TrWelcome, TrSuggestionPills } from '@opentiny/tiny-robot'
import { GeneratingStatus } from '@opentiny/tiny-robot-kit'
import { useTinyRobot } from '../composable/useTinyRobot'
import { appData } from '../tools/appData'
const props = defineProps<{
promptItems: any[]
suggestionPillItems: any[]
}>()
const {
fullscreen,
welcomeIcon,
promptItems: defaultPromptItems,
messages,
messageState,
inputMessage,
abortRequest,
roles,
handlePromptItemClick,
senderRef,
currentTemplate,
clearTemplate,
handleSendMessage,
handleMessageKeydown,
suggestionPillItems: defaultSuggestionPillItems,
handleSuggestionPillItemClick
} = useTinyRobot()
const customPromptItems = props.promptItems || defaultPromptItems
const customSuggestionPillItems = props.suggestionPillItems || defaultSuggestionPillItems
</script>
<style scoped lang="less">
.chat-input {
margin-top: 8px;
padding: 10px 15px;
}
.tr-container {
top: 64px !important;
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>

View File

@ -1,65 +0,0 @@
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 { globalConversation, handleSSEStream } from './utils.js'
import type { Ref } from 'vue'
/**
* AIClient的自定义 Dify
*
* const client = new AIClient({
* provider: 'custom',
* providerImplementation: new CustomModelProvider( config )
* });
*/
export class DifyModelProvider extends BaseModelProvider {
_messages: Ref<ChatCompletionRequest['messages']> = []
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: globalConversation.sessionId
},
conversation_id: globalConversation.id
})
})
await handleSSEStream(response, handler, this._messages, 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

@ -1,167 +0,0 @@
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, ref, watch } from 'vue'
import { DifyModelProvider } from './DifyModelProvider.js'
import type { SuggestionItem } from '@opentiny/tiny-robot'
const difyConfig: AIModelConfig = {
provider: 'custom',
apiUrl: 'https://api.dify.ai/v1',
apiKey: 'app-H0VJI4LqZ4KskdcA5a07pjXf'
}
export function useTinyRobot() {
const difyModelProvider = new DifyModelProvider(difyConfig)
const client = new AIClient({
providerImplementation: difyModelProvider,
...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' } }, '💡')
},
{
label: '智能操作网页',
description: '请帮我选中公司人员表中员工最多的公司',
icon: h('span', { style: { fontSize: '18px' } }, '🕹')
}
]
const handlePromptItemClick = (ev, item) => {
sendMessage(item.description)
}
const { messageManager } = useConversation({ client })
const { messages, messageState, inputMessage, sendMessage, abortRequest } = messageManager
difyModelProvider._messages = messages
const roles = {
assistant: {
type: 'markdown',
placement: 'start',
avatar: aiAvatar,
maxWidth: '80%'
},
user: {
placement: 'end',
avatar: userAvatar,
maxWidth: '80%'
}
}
// 建议按钮组,设置对话的模板
const suggestionPillItems = [
{
id: '1',
text: '公司人员表',
icon: h('span', { style: { fontSize: '18px' } }, '🏢')
}
]
function handleSuggestionPillItemClick(item: SuggestionItem) {
let templateText = `请对 [目标组件] ,执行 [操作]`
let currentInitialValue = { 目标组件: item.text, : '' }
if (senderRef.value) {
senderRef.value.setTemplate(templateText, currentInitialValue)
}
}
const senderRef = ref(null)
const currentTemplate = ref('')
const suggestionOpen = ref(false)
// 清除当前指令
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,
messageManager,
messages,
messageState,
inputMessage,
sendMessage,
abortRequest,
roles,
handlePromptItemClick,
senderRef,
currentTemplate,
suggestionOpen,
clearTemplate,
handleSendMessage,
handleMessageKeydown,
suggestionPillItems,
handleSuggestionPillItemClick
}
}

View File

@ -3,170 +3,10 @@
* *
*/ */
import type { ChatMessage, ChatCompletionResponse, StreamHandler } from '@opentiny/tiny-robot-kit' import { reactive } from 'vue'
import type { ChatCompletionRequest } from '@opentiny/tiny-robot-kit'
import { ref, reactive, type Ref } from 'vue'
export { $local, $session } from './storage' export { $local, $session } from './storage'
export const showTinyRobot = ref(true)
export const globalConversation = reactive({ export const globalConversation = reactive({
id: '', id: ''
sessionId: ''
}) })
/**
* SSE流式响应
* @param response fetch响应对象
* @param handler
*/
export async function handleSSEStream(
response: Response,
handler: StreamHandler,
message: Ref<ChatCompletionRequest['messages']>,
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 }
)
}
let messageIndex = 0
function printMessage(data, str: string, endln = false) {
handler.onData({
id: '',
created: data.created_at,
choices: [
{
index: messageIndex++,
delta: {
role: 'assistant',
content: str + (endln ? '\n\n' : '')
},
finish_reason: null
}
],
object: '',
model: ''
})
}
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() || ''
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])
// console.log('SSE data:', data)
if (data?.event === 'node_started') {
printMessage(data, `${data.data.title} 节点运行...`, true)
}
if (data?.event === 'node_finished') {
printMessage(
data,
`${data.data.title} 节点结束\n\n` +
(data.data.node_type === 'answer' ? `${data.data.outputs.answer}` : '')
)
}
if (data?.event === 'agent_log' && data.data.status === 'success' && data.data.label.startsWith('CALL')) {
printMessage(data, `--${data.data.label}(${JSON.stringify(data.data.data.output.tool_call_input)})`, true)
}
} 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

@ -3,9 +3,8 @@ 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, ...enUS }, zhCN: { ...zh, ...zhCN } } const messages = { enUS: { ...en }, zhCN: { ...zh } }
// $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 }) =>

View File

@ -2,9 +2,6 @@ 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'
@ -19,7 +16,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, t } from './i18n/index' import { i18n } 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'
@ -36,12 +33,6 @@ 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

@ -7,8 +7,6 @@ const Components = () => import('@/views/components-doc/index.vue')
const Docs = () => import('@/views/docs/docs.vue') const Docs = () => import('@/views/docs/docs.vue')
const Overview = () => import('@/views/overview.vue') const Overview = () => import('@/views/overview.vue')
const Features = () => import('@/views/features.vue') const Features = () => import('@/views/features.vue')
const Comprehensive = () => import('@/views/comprehensive/index.vue')
const Remoter = () => import('@/views/remoter/index.vue')
const context = import.meta.env.VITE_CONTEXT const context = import.meta.env.VITE_CONTEXT
@ -20,16 +18,6 @@ let routes = [
name: 'overview', name: 'overview',
children: [{ name: 'Overview', path: '', component: Overview, meta: { title: '组件总览 | TinyVue' } }] children: [{ name: 'Overview', path: '', component: Overview, meta: { title: '组件总览 | TinyVue' } }]
}, },
{
path: `${context}:all?/zh-CN/:theme/comprehensive`,
component: Comprehensive,
name: 'comprehensive'
},
{
path: `${context}:all?/zh-CN/:theme/remoter`,
component: Remoter,
name: 'remoter'
},
// 文档 // 文档
{ {
path: `${context}:all?/:lang/:theme/docs/:docId`, path: `${context}:all?/:lang/:theme/docs/:docId`,

View File

@ -1,4 +1,4 @@
import { reactive, computed, watch } from 'vue' import { reactive, computed } from 'vue'
import { useAutoStore } from './storage' import { useAutoStore } from './storage'
import { useMediaQuery } from './useMediaQuery' import { useMediaQuery } from './useMediaQuery'
import { ZH_CN_LANG, EN_US_LANG, LANG_KEY, LANG_PATH_MAP } from '../const' import { ZH_CN_LANG, EN_US_LANG, LANG_KEY, LANG_PATH_MAP } from '../const'
@ -8,9 +8,7 @@ const enPath = LANG_PATH_MAP[EN_US_LANG]
const appData = reactive({ const appData = reactive({
lang: useAutoStore('local', LANG_KEY, ZH_CN_LANG), lang: useAutoStore('local', LANG_KEY, ZH_CN_LANG),
theme: useAutoStore('local', '_theme', 'light'), theme: useAutoStore('local', '_theme', 'light'),
bpState: useMediaQuery([640, 1024, 1280]).matches, // 3点4区间 bp0,bp1,bp2,bp3 bpState: useMediaQuery([640, 1024, 1280]).matches // 3点4区间 bp0,bp1,bp2,bp3
showTinyRobot: false,
hasFloatRobot: false
}) })
const isZhCn = computed(() => appData.lang === ZH_CN_LANG) const isZhCn = computed(() => appData.lang === ZH_CN_LANG)
const appFn = { const appFn = {
@ -30,11 +28,4 @@ const appFn = {
// 为了和tiny-vue共享同一个响应变量 // 为了和tiny-vue共享同一个响应变量
window.appData = appData window.appData = appData
watch(
() => appData.showTinyRobot,
(value) => {
document.body.classList.toggle('docs-on-robot-show', value)
}
)
export { appData, appFn, isZhCn } export { appData, appFn, isZhCn }

View File

@ -88,7 +88,6 @@
</div> </div>
<div id="footer"></div> <div id="footer"></div>
</div> </div>
<robotChat v-if="appData.showTinyRobot && appData.hasFloatRobot"></robotChat>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -108,8 +107,6 @@ import McpDocs from '../../components/mcp-docs.vue'
import useTasksFinish from '../../composable/useTasksFinish' import useTasksFinish from '../../composable/useTasksFinish'
import { appData } from '../../tools/appData' import { appData } from '../../tools/appData'
import robotChat from '../../components/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'])
@ -449,9 +446,6 @@ defineExpose({ loadPage })
</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

@ -1,211 +0,0 @@
<template>
<div class="products-page">
<div class="page-header">
<h3>商品管理</h3>
</div>
<div class="page-content">
<div class="button-box">
<tiny-button type="info" @click="addProductToEdit"> 添加商品 </tiny-button>
<tiny-button type="danger" @click="removeProduct"> 删除商品 </tiny-button>
<tiny-button type="success" @click="saveProduct"> 保存 </tiny-button>
</div>
<tiny-grid
auto-resize
ref="gridRef"
:data="products"
:height="500"
:edit-config="{ trigger: 'click', mode: 'cell', showStatus: true }"
:tiny_mcp_config="{
server,
business: {
id: 'product-list',
description: '商品列表'
}
}"
>
<tiny-grid-column type="index" width="50" />
<tiny-grid-column type="selection" width="50" />
<tiny-grid-column title="商品图片" width="100">
<template #default="{ row }">
<tiny-image :src="row.image" :preview-src-list="[row.image]" class="product-image" />
</template>
</tiny-grid-column>
<tiny-grid-column field="name" title="商品名称" :editor="{ component: 'input' }" />
<tiny-grid-column
field="price"
:editor="{
component: 'input',
attrs: { type: 'number' }
}"
title="价格"
>
<template #default="{ row }"> ¥{{ row.price }} </template>
</tiny-grid-column>
<tiny-grid-column
field="stock"
:editor="{
component: 'input',
attrs: { type: 'number' }
}"
title="库存"
/>
<tiny-grid-column
field="category"
:editor="{
component: 'select',
options: [
{ label: '手机', value: 'phones' },
{ label: '笔记本', value: 'laptops' },
{ label: '平板', value: 'tablets' }
]
}"
title="分类"
>
<template #default="{ row }">
{{ categoryLabels[row.category] }}
</template>
</tiny-grid-column>
<tiny-grid-column
field="status"
:editor="{
component: 'select',
options: [
{ label: '上架', value: 'on' },
{ label: '下架', value: 'off' }
]
}"
title="状态"
>
<template #default="{ row }">
<tiny-tag :type="row.status === 'on' ? 'success' : 'warning'">
{{ row.status === 'on' ? '上架' : '下架' }}
</tiny-tag>
</template>
</tiny-grid-column>
</tiny-grid>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import productsData from './products.json'
import { $local } from '../../composable/utils'
import { useNextServer } from '@opentiny/next-vue'
import { TinyGrid, TinyGridColumn, TinyButton, TinyTag, TinyModal, TinyImage } from '@opentiny/vue'
if (!$local.products) {
$local.products = productsData
}
const products = ref($local.products)
const gridRef = ref(null)
const categoryLabels: Record<string, string> = {
phones: '手机',
laptops: '笔记本',
tablets: '平板'
}
//
const addProductToEdit = async () => {
gridRef?.value?.insert({
'image': 'https://img1.baidu.com/it/u=1559062020,1043707656&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500',
price: 10000,
stock: 100,
category: 'phones',
status: 'on'
})
}
const removeProduct = () => {
const selectedRows = gridRef?.value?.getSelectRecords()
if (selectedRows.length === 0) {
TinyModal.confirm({
message: '请选择要删除的商品',
title: '删除商品',
status: 'warning'
})
return
}
if (selectedRows.length > 0) {
gridRef?.value?.removeSelecteds()
}
}
const saveProduct = () => {
setTimeout(() => {
const data = gridRef?.value?.getTableData()
$local.products = data.tableData
TinyModal.message({
message: '保存成功',
status: 'success'
})
}, 1000)
}
const { server } = useNextServer({
serverInfo: { name: 'commodity-config', version: '1.0.0' }
})
</script>
<style scoped lang="less">
.products-page {
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 16px 20px;
background-color: #ffffff;
border-radius: 8px;
position: relative;
border: 1px solid #edf2f7;
&::before {
content: '';
position: absolute;
top: 50%;
left: 0;
width: 4px;
height: 24px;
background: #1677ff;
border-radius: 2px;
transform: translateY(-50%);
}
h3 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1f2937;
position: relative;
padding-left: 20px;
letter-spacing: 0.3px;
}
}
}
.button-box {
display: flex;
gap: 16px;
margin-bottom: 20px;
justify-content: flex-end;
}
.loading-state {
padding: 20px;
}
.product-image {
width: 40px;
height: 40px;
border-radius: 4px;
}
.page-content {
padding: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.03);
}
</style>

View File

@ -1,380 +0,0 @@
<template>
<div class="header">
<div class="qr-code-trigger" @click="showQrCode = true">
<IconAi class="qr-icon" />
<div class="qr-text-wrapper">
<span class="qr-label">手机遥控</span>
<span class="qr-desc">扫码体验移动端操作</span>
</div>
</div>
</div>
<div class="app-container">
<!-- 主体内容区域 -->
<div class="main-content">
<Demo />
</div>
<div class="right-panel" :class="{ collapsed: !appData.showTinyRobot }">
<tiny-robot-chat :prompt-items="promptItems" :suggestion-pill-items="suggestionPillItems" />
</div>
<IconAi @click="appData.showTinyRobot = !appData.showTinyRobot" class="style-settings-icon"></IconAi>
</div>
<!-- QR Code Modal -->
<Teleport to="body">
<div class="qr-modal" v-if="showQrCode" @click.self="showQrCode = false">
<div class="qr-modal-content">
<div class="qr-modal-header">
<h3>手机遥控体验</h3>
</div>
<div class="qr-modal-body">
<div class="qr-wrapper">
<tiny-qr-code :value="sessionUrl" :size="200" color="#1677ff"></tiny-qr-code>
<div class="qr-status">
<div class="status-dot"></div>
<span>链接已生成</span>
</div>
</div>
<div class="qr-instructions">
<p class="instruction-title">使用说明</p>
<ol class="instruction-steps">
<li>使用微信扫一扫扫描上方二维码</li>
<li>然后点击微信右上角的 "..." 图标</li>
<li>选择在默认浏览器中打开</li>
<li>可以选择直接语音交互也可使用AI对话框进行交互</li>
</ol>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { watch, ref, h } from 'vue'
import TinyRobotChat from '@/components/tiny-robot-chat.vue'
import { globalConversation } from '@/composable/utils'
import { IconAi } from '@opentiny/tiny-robot-svgs'
import CryptoJS from 'crypto-js'
import { TinyQrCode } from '@opentiny/vue'
import Demo from './Demo.vue'
import { appData } from '@/tools/appData'
appData.showTinyRobot = true
const showQrCode = ref(false)
const sessionUrl = ref('placeholder')
const promptItems = [
{
label: '识别网页的内容',
description: '帮我在商品列表中查询最贵的手机和最便宜的笔记本',
icon: h('span', { style: { fontSize: '18px' } }, '💡')
},
{
label: '智能操作网页',
description: '帮我在商品列表中删除最贵的且分类为手机的商品',
icon: h('span', { style: { fontSize: '18px' } }, '🕹')
},
{
label: '智能操作网页',
description: '帮我在商品列表中添加一个华为p60品牌的手机商品',
icon: h('span', { style: { fontSize: '18px' } }, '🕹')
}
]
const suggestionPillItems = [
{
id: '1',
text: '商品列表',
icon: h('span', { style: { fontSize: '18px' } }, '🏢')
}
]
watch(
() => globalConversation.sessionId,
(newVal) => {
if (newVal) {
const encryptedId = CryptoJS.AES.encrypt(newVal, 'secret-session-id').toString()
const secretId = encodeURIComponent(encryptedId)
sessionUrl.value = `https://agent.icjs.ink?id=${secretId}`
}
},
{ immediate: true }
)
</script>
<style scoped>
.header {
width: calc(100% - 502px);
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
background-color: #f5f5f5;
}
.qr-code-trigger {
display: flex;
align-items: center;
cursor: pointer;
padding: 12px 20px;
border-radius: 12px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: linear-gradient(135deg, #1677ff 0%, #06b6d4 100%);
position: relative;
overflow: hidden;
}
.qr-code-trigger::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0));
transform: translateX(-100%);
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.qr-code-trigger:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(22, 119, 255, 0.3);
}
.qr-code-trigger:hover::before {
transform: translateX(100%);
}
.qr-icon {
font-size: 24px;
margin-right: 12px;
color: white;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
animation: float 3s ease-in-out infinite;
}
.qr-text-wrapper {
display: flex;
flex-direction: column;
}
.qr-label {
font-size: 16px;
font-weight: 600;
color: white;
margin-bottom: 2px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.qr-desc {
font-size: 13px;
color: rgba(255, 255, 255, 0.9);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
}
.qr-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
animation: fadeIn 0.3s ease;
}
.qr-modal-content {
background: white;
border-radius: 12px;
padding: 32px;
width: 460px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.qr-modal-header {
margin-bottom: 32px;
}
.qr-modal-header h3 {
font-size: 22px;
font-weight: 600;
color: #1f2937;
}
.close-icon {
cursor: pointer;
font-size: 20px;
color: #999;
transition: color 0.3s;
}
.close-icon:hover {
color: #666;
}
.qr-modal-body {
display: flex;
flex-direction: column;
align-items: center;
}
.qr-wrapper {
background-color: #f8f9fa;
padding: 24px;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 24px;
position: relative;
animation: glow 2s ease-in-out infinite;
}
.qr-wrapper::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
background: linear-gradient(45deg, #1677ff, #10b981, #1677ff);
border-radius: 10px;
z-index: -1;
filter: blur(8px);
opacity: 0.5;
transition: opacity 0.3s ease;
}
.qr-wrapper:hover::before {
opacity: 0.7;
}
@keyframes glow {
0% {
box-shadow: 0 0 5px rgba(22, 119, 255, 0.2), 0 0 10px rgba(22, 119, 255, 0.2), 0 0 15px rgba(22, 119, 255, 0.2);
}
50% {
box-shadow: 0 0 10px rgba(22, 119, 255, 0.3), 0 0 20px rgba(22, 119, 255, 0.3), 0 0 30px rgba(22, 119, 255, 0.3);
}
100% {
box-shadow: 0 0 5px rgba(22, 119, 255, 0.2), 0 0 10px rgba(22, 119, 255, 0.2), 0 0 15px rgba(22, 119, 255, 0.2);
}
}
.qr-status {
display: flex;
align-items: center;
margin-top: 16px;
background: #fff;
padding: 6px 12px;
border-radius: 16px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.status-dot {
width: 8px;
height: 8px;
background-color: #10b981;
border-radius: 50%;
margin-right: 8px;
animation: pulse 2s infinite;
}
.qr-instructions {
background-color: #f8f9fa;
border-radius: 8px;
padding: 20px;
}
.instruction-title {
font-size: 16px;
font-weight: 500;
color: #1f2937;
margin-bottom: 12px;
}
.instruction-steps {
margin: 0;
padding-left: 20px;
}
.instruction-steps li {
color: #4b5563;
margin-bottom: 8px;
font-size: 14px;
}
.instruction-steps li:last-child {
margin-bottom: 0;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4);
}
70% {
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.app-container {
display: flex;
height: 100%;
position: relative;
}
.main-content {
padding: 10px 10px;
height: 100%;
width: calc(100% - 502px);
position: relative;
}
.right-panel {
width: 480px;
height: 100%;
position: relative;
background: #fff;
border-left: 1px solid #e4e7ed;
}
.right-panel.collapsed {
width: 0;
overflow: hidden;
}
.style-settings-icon {
position: fixed;
bottom: 100px;
right: 100px;
font-size: 24px;
z-index: 30;
cursor: pointer;
}
</style>

View File

@ -1,99 +0,0 @@
[
{
"id": 1,
"name": "iPhone 16",
"price": 5999,
"description": "苹果手机",
"image": "https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg.alicdn.com%2Fbao%2Fuploaded%2FO1CN011qHTQ81edxfMcnfNR_%21%216000000003895-0-yinhe.jpg_300x300.jpg&refer=http%3A%2F%2Fimg.alicdn.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1750986769&t=4570d81c9f0301ab1745c75dfff9f272",
"category": "phones",
"stock": 100,
"status": "on",
"createdAt": "2024-03-20",
"updatedAt": "2025-05-29T21:56:30.481Z"
},
{
"id": 2,
"name": "MacBook Pro",
"price": 12999,
"description": "笔记本电脑",
"image": "https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fg-search1.alicdn.com%2Fimg%2Fbao%2Fuploaded%2Fi3%2F1831488473%2FO1CN01I8MRyC2CSgWGbhVjJ_%21%211831488473.jpg_300x300.jpg&refer=http%3A%2F%2Fg-search1.alicdn.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1750986810&t=9eb9c9e0765244130200dd0f935b9cea",
"category": "laptops",
"stock": 50,
"status": "off",
"createdAt": "2024-03-20",
"updatedAt": "2024-03-20"
},
{
"id": 3,
"name": "iPad",
"price": 2399,
"description": "平板电脑",
"image": "https://respic.3d66.com/coverimg/cache/f2d1/53893cd4d00efe87132f1e3371b72339.jpg%21medium-size-2?v=17611204&k=D41D8CD98F00B204E9800998ECF8427E",
"category": "tablets",
"stock": 12999,
"status": "on",
"createdAt": "2025-05-27T00:49:29.993Z",
"updatedAt": "2025-05-29T16:11:49.743Z"
},
{
"id": 4,
"name": "Huawei Pura 70",
"price": 6499,
"description": "华为手机",
"image": "https://img1.baidu.com/it/u=1559062020,1043707656&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500",
"category": "phones",
"stock": 1999,
"status": "on",
"createdAt": "2025-05-27T00:54:38.771Z",
"updatedAt": "2025-05-29T22:29:40.124Z"
},
{
"id": 5,
"name": "Huawei Mate XT ultimate",
"price": 23999,
"description": "华为手机 非凡大师",
"image": "https://img2.baidu.com/it/u=2892977680,910281472&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500",
"category": "phones",
"stock": 100,
"status": "on",
"createdAt": "2025-05-27T00:54:38.771Z",
"updatedAt": "2025-05-30T17:40:59.199Z"
},
{
"id": 6,
"name": "小米15",
"price": 4999,
"description": "小米手机",
"image": "https://img2.baidu.com/it/u=2892977680,910281472&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500",
"category": "phones",
"stock": 222,
"status": "off",
"createdAt": "2025-05-29T00:30:02.226Z",
"updatedAt": "2025-05-29T16:14:35.150Z"
},
{
"id": 7,
"name": "小米 13",
"price": 3999,
"description": "小米手机",
"image": "https://img1.baidu.com/it/u=809120544,2106407569&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=500",
"category": "phones",
"stock": 2222,
"status": "on",
"createdAt": "2025-05-29T00:32:46.308Z",
"updatedAt": "2025-05-30T21:28:28.543Z"
},
{
"id": 8,
"name": "Vivo X90",
"price": 2999,
"description": "Vivo手机",
"image": "https://img2.baidu.com/it/u=2892977680,910281472&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500",
"category": "phones",
"stock": 3999,
"status": "on",
"createdAt": "2025-05-27T00:54:38.771Z",
"updatedAt": "2025-05-30T17:40:59.199Z"
}
]

View File

@ -1,37 +0,0 @@
// 用户类型
export interface User {
id: number
username: string
avatar?: string
}
// 商品类型
export interface Product {
id: number
name: string
price: number
description?: string
image: string
category: string
stock: number
status: 'on' | 'off' // on: 上架, off: 下架
createdAt?: string
updatedAt?: string
}
// 登录表单类型
export interface LoginForm {
username: string
password: string
}
// 商品表单类型
export interface ProductForm {
name: string
price: number
description: string
image: string
category: string
stock: number
status: 'on' | 'off'
}

View File

@ -1,63 +0,0 @@
<script setup lang="ts">
// CryptoJS
import CryptoJS from 'crypto-js'
import { reactive } from 'vue'
import { TinyButton, TinyDrawer } from '@opentiny/vue'
import RobotChat from '../../components/tiny-robot-chat.vue'
import Sound from './sound.vue'
import { globalConversation } from '../../composable/utils'
//
const secretKey = 'secret-session-id'
// URL
const urlParams = new URLSearchParams(window.location.search)
const encryptedId = urlParams.get('id')
const state = reactive({ isShow: true, showChat: false, showSound: false })
if (encryptedId) {
// id
const bytes = CryptoJS.AES.decrypt(encryptedId, secretKey)
const originalText = bytes.toString(CryptoJS.enc.Utf8)
// id window.sessionId
globalConversation.sessionId = originalText
}
const showMoter = (type) => {
state.isShow = false
state[type] = true
}
</script>
<template>
<div>
<RobotChat v-if="state.showChat" />
<Sound v-if="state.showSound" />
<tiny-drawer
v-if="state.isShow"
title="请选择控制器"
placement="bottom"
:mask-closable="false"
:show-close="false"
v-model:visible="state.isShow"
height="400px"
>
<div class="link-box">
<tiny-button @click="showMoter('showSound')" type="info" size="large">语音控制器</tiny-button>
<tiny-button @click="showMoter('showChat')" type="info" size="large">综合控制器</tiny-button>
</div>
</tiny-drawer>
</div>
</template>
<style scoped lang="less">
.link-box {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.tiny-button {
margin-bottom: 20px;
}
}
</style>

View File

@ -1,349 +0,0 @@
<template>
<div class="sound-container">
<div class="messages-container" ref="messagesContainer">
<template v-if="messages && messages.length > 0">
<message-card
v-for="(msg, index) in messages"
:key="index"
:role="msg.role === 'system' ? 'assistant' : msg.role"
:message="msg.content"
:timestamp="messageTimestamps[index]"
/>
</template>
<div v-else class="empty-message">
<p>暂无对话记录</p>
</div>
</div>
<div class="sound-box">
<div class="recording-status" v-show="isTalk">
<div class="wave-animation"></div>
<span>{{ recordingTime }}s</span>
</div>
<tiny-button
@touchstart.prevent="handleStart"
@touchend.prevent="handleEnd"
@touchcancel.prevent="handleEnd"
:type="isTalk ? 'danger' : 'info'"
class="talk-button"
size="large"
:reset-time="0"
:disabled="!isSupported"
>
{{ buttonText }}
</tiny-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onUnmounted, watch, nextTick } from 'vue'
import { TinyButton, TinyNotify } from '@opentiny/vue'
import { useTinyRobot } from '../../composable/useTinyRobot'
import MessageCard from '../../components/MessageCard.vue'
//
interface SpeechRecognitionResult {
[index: number]: {
transcript: string
confidence: number
}
isFinal?: boolean
length: number
}
interface SpeechRecognitionResultList {
[index: number]: SpeechRecognitionResult
length: number
}
interface SpeechRecognitionEvent extends Event {
results: SpeechRecognitionResultList
}
interface SpeechRecognitionErrorEvent extends Event {
error: string
message: string
}
// SpeechRecognition
const getSpeechRecognition = () => {
const SpeechRecognition =
(window as any).SpeechRecognition ||
(window as any).webkitSpeechRecognition ||
(window as any).mozSpeechRecognition ||
(window as any).msSpeechRecognition
return SpeechRecognition
}
//
const isTalk = ref(false)
const isSupported = ref(false)
const recordingTime = ref(0)
const maxRecordingTime = 60
let recognition: any = null
let recordingTimer: number | null = null
const messagesContainer = ref<HTMLElement | null>(null)
const messageTimestamps = ref<number[]>([])
const { sendMessage, messages } = useTinyRobot()
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
//
const buttonText = computed(() => {
if (!isSupported.value) return '当前浏览器不支持语音识别'
if (isTalk.value) return `录音中 ${recordingTime.value}s`
return '长按说话'
})
//
watch(
() => messages.value,
async (newMessages) => {
if (!newMessages) return
//
if (messageTimestamps.value.length < newMessages.length) {
const timestamp = Date.now()
messageTimestamps.value = newMessages.map((_, index) => messageTimestamps.value[index] || timestamp)
}
//
await nextTick()
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
},
{ deep: true }
)
//
const initSpeechRecognition = () => {
const SpeechRecognition = getSpeechRecognition()
if (SpeechRecognition) {
isSupported.value = true
recognition = new SpeechRecognition()
recognition.lang = 'zh-CN'
recognition.continuous = false
recognition.interimResults = false
recognition.maxAlternatives = 1
// Safari
if (isSafari) {
recognition.interimResults = true
}
let finalTranscript = ''
recognition.addEventListener('result', (event: SpeechRecognitionEvent) => {
const transcript = event.results[0][0].transcript
if (transcript.trim()) {
finalTranscript = transcript
}
})
recognition.addEventListener('end', () => {
stopRecording()
//
if (finalTranscript.trim()) {
sendMessage(finalTranscript)
finalTranscript = ''
}
})
recognition.addEventListener('error', (event: SpeechRecognitionErrorEvent) => {
handleRecognitionError(event)
finalTranscript = ''
})
} else {
isSupported.value = false
TinyNotify({
type: 'error',
title: '提示',
message: '您的浏览器不支持语音识别请使用Chrome、Safari或Edge浏览器',
position: 'top-right',
duration: 5000
})
}
}
//
const handleRecognitionError = (event: SpeechRecognitionErrorEvent) => {
stopRecording()
let errorMessage = '语音识别出错'
switch (event.error) {
case 'not-allowed':
errorMessage = '请允许浏览器使用麦克风'
break
case 'no-speech':
errorMessage = '未检测到语音,请重试'
break
case 'network':
errorMessage = '网络连接出错,请检查网络后重试'
break
case 'aborted':
return //
default:
errorMessage = `语音识别失败: ${event.message || '未知错误'}`
}
TinyNotify({
type: 'error',
title: '语音识别出错',
message: errorMessage,
position: 'top-right',
duration: 3000
})
}
//
const handleStart = async (event: TouchEvent) => {
event.preventDefault()
event.stopPropagation()
if (!isSupported.value) {
TinyNotify({
type: 'error',
title: '提示',
message: '当前浏览器不支持语音识别',
position: 'top-right',
duration: 3000
})
return
}
if (isTalk.value) return
try {
recognition.start()
isTalk.value = true
recordingTime.value = 0
startRecordingTimer()
} catch (error) {
handleRecognitionError({
error: 'start_error',
message: '启动语音识别失败,请重试'
} as SpeechRecognitionErrorEvent)
}
}
//
const handleEnd = () => {
if (!isTalk.value) return
try {
recognition.stop()
} catch (error) {
console.error('停止录音失败:', error)
}
stopRecording()
}
//
const startRecordingTimer = () => {
recordingTimer = window.setInterval(() => {
recordingTime.value++
if (recordingTime.value >= maxRecordingTime) {
handleEnd()
TinyNotify({
type: 'warning',
title: '提示',
message: '已达到最大录音时长',
position: 'top-right',
duration: 3000
})
}
}, 1000)
}
//
const stopRecording = () => {
isTalk.value = false
if (recordingTimer) {
clearInterval(recordingTimer)
recordingTimer = null
}
}
//
initSpeechRecognition()
//
onUnmounted(() => {
if (recordingTimer) {
clearInterval(recordingTimer)
}
if (recognition) {
try {
recognition.abort()
} catch (error) {
console.error('清理语音识别失败:', error)
}
}
})
</script>
<style scoped lang="less">
.sound-container {
display: flex;
flex-direction: column;
height: 100vh;
position: relative;
.messages-container {
flex: 1;
overflow-y: auto;
padding: 16px;
margin-bottom: 120px;
.empty-message {
text-align: center;
color: #999;
padding: 20px;
}
}
.sound-box {
position: fixed;
bottom: 0;
left: 0;
right: 0;
margin: 10px;
background: white;
padding: 10px;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
.recording-status {
text-align: center;
margin-bottom: 10px;
.wave-animation {
width: 100px;
height: 20px;
margin: 0 auto 5px;
background: linear-gradient(90deg, #ff4d4f 25%, #ff7875 50%, #ff4d4f 75%);
background-size: 200% 100%;
animation: wave 2s linear infinite;
}
}
.talk-button {
width: 100%;
margin: 20px 0;
transition: all 0.3s;
&:active {
transform: scale(0.98);
}
}
}
}
@keyframes wave {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
</style>