xiaoyuan-assistant 0.5.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,519 @@
1
+ <template>
2
+ <div class="xy-root">
3
+ <button
4
+ v-if="!expanded"
5
+ class="xy-fab"
6
+ :class="stateClass"
7
+ type="button"
8
+ aria-label="打开小园"
9
+ @click="activate"
10
+ >
11
+ <span class="xy-fab-glow"></span>
12
+ <span class="xy-avatar">园</span>
13
+ <span v-if="wakeListening" class="xy-live-dot"></span>
14
+ </button>
15
+
16
+ <transition name="xy-pop">
17
+ <section v-if="expanded" class="xy-panel" aria-label="小园智能助手">
18
+ <header class="xy-header">
19
+ <div class="xy-title-wrap">
20
+ <div class="xy-avatar small">园</div>
21
+ <div>
22
+ <div class="xy-title">小园</div>
23
+ <div class="xy-status">
24
+ <span class="xy-status-dot" :class="stateClass"></span>
25
+ {{ stateText }}
26
+ </div>
27
+ </div>
28
+ </div>
29
+ <div class="xy-header-actions">
30
+ <button type="button" class="xy-icon-btn" title="收起" @click="collapse" aria-label="收起">
31
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14"/></svg>
32
+ </button>
33
+ <button type="button" class="xy-icon-btn" title="停止播报" @click="stopSpeech" aria-label="停止播报">
34
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4l16 16M9.5 6.2A7 7 0 0 1 19 12v1.2M4.8 9.1A7 7 0 0 0 5 13"/></svg>
35
+ </button>
36
+ </div>
37
+ </header>
38
+
39
+ <div class="xy-subtitle" :class="{ show: subtitle }">
40
+ {{ subtitle }}
41
+ </div>
42
+
43
+ <main ref="messageBox" class="xy-messages">
44
+ <div v-if="!messages.length" class="xy-empty">
45
+ <div class="xy-empty-orb"><span>园</span></div>
46
+ <div class="xy-empty-title">你好,我是小园</div>
47
+ <div class="xy-empty-text">可以帮你操控大屏、读取数据并进行分析。</div>
48
+ <div class="xy-quick-row">
49
+ <span>🎙 你好小园</span>
50
+ <span>⌨ 输入指令</span>
51
+ <span>📊 数据分析</span>
52
+ </div>
53
+ </div>
54
+
55
+ <article
56
+ v-for="item in messages"
57
+ :key="item.id"
58
+ class="xy-msg"
59
+ :class="item.role"
60
+ >
61
+ <div class="xy-msg-head">
62
+ <div class="xy-msg-avatar" :class="item.role">
63
+ {{ item.role === 'user' ? '你' : '园' }}
64
+ </div>
65
+ <div class="xy-msg-label">{{ item.role === 'user' ? '你' : '小园' }}</div>
66
+ </div>
67
+ <div class="xy-msg-bubble">{{ item.displayText }}</div>
68
+ </article>
69
+ </main>
70
+
71
+ <footer class="xy-footer">
72
+ <div v-if="interimText" class="xy-interim">{{ interimText }}</div>
73
+ <div class="xy-input-row">
74
+ <textarea
75
+ v-model="text"
76
+ class="xy-input"
77
+ rows="2"
78
+ placeholder="输入指令,或按住语音按钮说话…"
79
+ @keydown.enter.exact.prevent="submitText"
80
+ ></textarea>
81
+ <button
82
+ type="button"
83
+ class="xy-mic"
84
+ :class="{ active: listening }"
85
+ :disabled="processing"
86
+ @click="toggleListening"
87
+ >
88
+ <span v-if="listening" class="xy-mic-bars"><i></i><i></i><i></i></span>
89
+ <svg v-else viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15.5a3.5 3.5 0 0 0 3.5-3.5V7a3.5 3.5 0 0 0-7 0v5a3.5 3.5 0 0 0 3.5 3.5ZM6.5 11.5a5.5 5.5 0 0 0 11 0M12 17v4M8.5 21h7"/></svg>
90
+ </button>
91
+ <button
92
+ type="button"
93
+ class="xy-send"
94
+ :disabled="!text.trim()"
95
+ @click="submitText"
96
+ >发送</button>
97
+ </div>
98
+ <div class="xy-hint">
99
+ 唤醒词:{{ wakeWord }} · 支持文字和语音
100
+ </div>
101
+ </footer>
102
+ </section>
103
+ </transition>
104
+ </div>
105
+ </template>
106
+
107
+ <script setup>
108
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
109
+ import { useXiaoyuan } from '../index.js'
110
+
111
+ const { manager, speech, options } = useXiaoyuan()
112
+ const wakeWord = options.wakeWord || '你好小园'
113
+ const autoCollapseMs = options.autoCollapseMs ?? 0
114
+
115
+ const expanded = ref(false)
116
+ const listening = ref(false)
117
+ const wakeListening = ref(false)
118
+ const processing = ref(false)
119
+ const text = ref('')
120
+ const interimText = ref('')
121
+ const subtitle = ref('')
122
+ const messages = ref([])
123
+ const messageBox = ref(null)
124
+ let wakeController = null
125
+ let collapseTimer = null
126
+ let subtitleTimer = null
127
+ let speechQueue = Promise.resolve()
128
+ let speechQueueToken = 0
129
+ let commandSessionId = 0
130
+
131
+ const state = computed(() => {
132
+ if (processing.value) return 'processing'
133
+ if (listening.value) return 'listening'
134
+ if (wakeListening.value) return 'standby'
135
+ if (expanded.value) return 'ready'
136
+ return 'idle'
137
+ })
138
+
139
+ const stateClass = computed(() => `state-${state.value}`)
140
+ const stateText = computed(() => ({
141
+ idle: '点击唤醒',
142
+ standby: '正在等待唤醒',
143
+ ready: '待命中,可输入指令',
144
+ listening: '正在聆听',
145
+ processing: '正在处理'
146
+ }[state.value] || '待命'))
147
+
148
+ function addMessage(role, text, meta = '') {
149
+ const id = `${Date.now()}-${Math.random()}`
150
+ const value = String(text ?? '')
151
+ const message = {
152
+ id,
153
+ role,
154
+ text: value,
155
+ displayText: role === 'assistant' ? '' : value,
156
+ meta,
157
+ typing: role === 'assistant'
158
+ }
159
+
160
+ // 关键修复:messages 是 ref 数组,push 后 Vue 会把对象转成 Proxy。
161
+ // 之前直接修改 push 前的 raw message 对象 displayText,不会触发界面更新,
162
+ // 因此“打字机”实际上在内部执行了,但页面始终显示空字符串。
163
+ messages.value.push(message)
164
+ const messageIndex = messages.value.length - 1
165
+
166
+ nextTick(() => {
167
+ if (messageBox.value) messageBox.value.scrollTop = messageBox.value.scrollHeight
168
+ })
169
+
170
+ if (role !== 'assistant') return
171
+
172
+ let index = 0
173
+ const step = value.length > 180 ? 2 : 1
174
+ const interval = value.length > 500 ? 18 : 28
175
+
176
+ const tick = () => {
177
+ const current = messages.value[messageIndex]
178
+ if (!current) return
179
+
180
+ index = Math.min(value.length, index + step)
181
+ // 必须修改 Proxy 上的数据,确保 Vue 响应式更新。
182
+ current.displayText = value.slice(0, index)
183
+
184
+ if (messageBox.value) messageBox.value.scrollTop = messageBox.value.scrollHeight
185
+
186
+ if (index < value.length) {
187
+ current.typing = true
188
+ window.setTimeout(tick, interval)
189
+ } else {
190
+ current.typing = false
191
+ }
192
+ }
193
+
194
+ window.setTimeout(tick, 16)
195
+ }
196
+
197
+ function showSubtitle(value, ttl = 5000) {
198
+ subtitle.value = value
199
+ window.clearTimeout(subtitleTimer)
200
+ if (ttl > 0) subtitleTimer = window.setTimeout(() => { subtitle.value = '' }, ttl)
201
+ }
202
+
203
+ function collapse() {
204
+ expanded.value = false
205
+ if (collapseTimer) window.clearTimeout(collapseTimer)
206
+ }
207
+
208
+ async function activate(reason = 'manual') {
209
+ // 用户点击/唤醒时先完成本地前置 TTS 的解锁与预解码,避免收到识别结果后播放被浏览器拦截。
210
+ await speech.unlockTTS?.()
211
+ expanded.value = true
212
+ showSubtitle(reason === 'wake' ? '小园已唤醒,请说出您的指令。' : '小园已唤醒,请输入或说出您的指令。')
213
+ if (reason === 'wake') {
214
+ wakeController?.pause?.()
215
+ wakeListening.value = false
216
+ // 被唤醒后不再立即播报欢迎语,避免 TTS 与麦克风识别互相干扰。
217
+ // 被唤醒后自动进入一次性聆听,用户无需再次点击麦克风。
218
+ window.setTimeout(() => {
219
+ if (!expanded.value || listening.value || processing.value) return
220
+ void listenForCommand({ auto: true })
221
+ }, 180)
222
+ }
223
+ }
224
+
225
+ async function speakWithRetry(textToSpeak, speakOptions = {}, retryCount = 2) {
226
+ const attempts = Math.max(1, Number(retryCount) + 1)
227
+ let lastError = null
228
+
229
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
230
+ try {
231
+ const started = await speech.speak(textToSpeak, speakOptions)
232
+ if (started !== false) return true
233
+ } catch (error) {
234
+ lastError = error
235
+ console.warn('[小园 TTS] 播报失败,准备重试:', error?.message || error)
236
+ }
237
+
238
+ if (attempt < attempts - 1) {
239
+ await new Promise((resolve) => window.setTimeout(resolve, 180 + attempt * 220))
240
+ }
241
+ }
242
+
243
+ if (lastError) console.warn('[小园 TTS] 重试结束:', lastError?.message || lastError)
244
+ return false
245
+ }
246
+
247
+ function queueSpeak(textToSpeak) {
248
+ if (!options.enableTTS || !textToSpeak) return
249
+ const token = ++speechQueueToken
250
+ speechQueue = speechQueue
251
+ .catch(() => {})
252
+ .then(() => {
253
+ if (token !== speechQueueToken) return undefined
254
+ return speakWithRetry(textToSpeak, {}, 1)
255
+ })
256
+ }
257
+
258
+ function stopSpeech() {
259
+ speechQueueToken += 1
260
+ speechQueue = Promise.resolve()
261
+ speech.stopSpeaking()
262
+ }
263
+
264
+ // 新指令到来时立即打断上一条播报,并让上一条任务后续产生的播报失效。
265
+ function interruptForNewCommand() {
266
+ commandSessionId += 1
267
+ stopSpeech()
268
+ subtitle.value = ''
269
+ interimText.value = ''
270
+ return commandSessionId
271
+ }
272
+
273
+ async function toggleListening() {
274
+ if (listening.value) return
275
+ await speech.unlockTTS?.()
276
+ await listenForCommand()
277
+ }
278
+
279
+ async function listenForCommand({ auto = false } = {}) {
280
+ // 在开始语音识别时就完成本地前置 TTS 的 AudioContext 解锁/预解码,
281
+ // 这样识别结束后收到结果时无需依赖新的用户手势。
282
+ await speech.unlockTTS?.()
283
+
284
+ if (!speech.supported()) {
285
+ showSubtitle('当前浏览器不支持语音识别,请使用 Chrome / Edge。', 7000)
286
+ return
287
+ }
288
+
289
+ // 手动或唤醒后的单次聆听期间,暂停持续唤醒监听,避免两个 Recognition 实例抢麦克风。
290
+ wakeController?.pause?.()
291
+ wakeListening.value = false
292
+
293
+ listening.value = true
294
+ interimText.value = ''
295
+ showSubtitle('正在聆听,请说出你的指令…', 0)
296
+
297
+ try {
298
+ const spoken = await speech.listenOnce()
299
+ interimText.value = ''
300
+
301
+ if (spoken) {
302
+ await handleCommand(spoken, 'voice')
303
+ } else if (auto) {
304
+ showSubtitle('没有听到您的声音,请再说一次。', 5000)
305
+ }
306
+ } catch (error) {
307
+ interimText.value = ''
308
+ const message = String(error?.message || error || '语音识别失败')
309
+ addMessage('assistant', message)
310
+ showSubtitle(message, 6000)
311
+ queueSpeak(message)
312
+ } finally {
313
+ listening.value = false
314
+ // 当前指令处理结束后恢复全局唤醒监听。
315
+ if (!processing.value && options.enableWakeWord) {
316
+ wakeController?.resume?.()
317
+ wakeListening.value = true
318
+ }
319
+ if (!processing.value) {
320
+ showSubtitle('待命中,可继续输入或说话。', 5000)
321
+ }
322
+ }
323
+ }
324
+
325
+ function normalizeAnalysisSubject(value = '') {
326
+ let text = String(value || '')
327
+ .replace(/^[\s,,。;;::、]+/, '')
328
+ .replace(/^(?:请|请帮我|帮我|帮忙|麻烦|想看一下|想看看|想了解|查看一下|查看|看一下|看看|查询一下|查询|获取一下|获取|读取一下|读取|展示一下|展示|分析一下|分析|帮我分析一下|帮我分析|分析下|看下|了解一下|了解)\s*/i, '')
329
+ .trim()
330
+
331
+ // 去掉已经被拼进分析文本里的时间前缀,避免出现“正在分析2025年……”
332
+ text = text.replace(/^(?:19|20)\d{2}年?\s*[,,、]?\s*/i, '').trim()
333
+ return text || '当前数据'
334
+ }
335
+
336
+ function buildStepSpeech(command = {}, description = '') {
337
+ if (command?.type === 'analysis') {
338
+ const instruction = normalizeAnalysisSubject(command?.instruction || description)
339
+ return `正在分析${instruction},请稍等。`
340
+ }
341
+
342
+ const name = String(command?.function || command?.name || '')
343
+ const params = command?.params || {}
344
+ const value = params?.value ?? params?.param ?? params?.target ?? params?.id ?? params?.name
345
+ const valueText = value !== undefined && value !== null && String(value) !== '' ? String(value) : ''
346
+
347
+ if (/^changeYear$/i.test(name) && valueText) {
348
+ return `已切换到${valueText}年。`
349
+ }
350
+
351
+ if (/handleMenuClick|menu/i.test(name) && valueText) {
352
+ return `已切换到${valueText}菜单。`
353
+ }
354
+
355
+ const text = String(description || name || '当前指令').trim()
356
+ return `已${text}${/[。!?!?]$/.test(text) ? '' : '。'}`
357
+ }
358
+
359
+ async function submitText() {
360
+ await speech.unlockTTS?.()
361
+ if (!text.value.trim()) return
362
+ const value = text.value.trim()
363
+ text.value = ''
364
+ await handleCommand(value, 'text')
365
+ }
366
+
367
+ async function handleCommand(commandText, source) {
368
+ const sessionId = interruptForNewCommand()
369
+ // 文本按钮/语音按钮触发时属于用户手势,提前解锁 FreeTTS Web Audio。
370
+ void speech.unlockTTS?.()
371
+ processing.value = true
372
+ addMessage('user', commandText, source === 'voice' ? '语音输入' : '文字输入')
373
+
374
+ showSubtitle('收到指令,请您稍等。', 0)
375
+
376
+ // 固定“收到指令,请您稍等”只负责异步提示,不占用主业务流程。
377
+ // 不等待它请求完成、也不等待它播放开始;本地 voice 音频使用独立 Audio 通道,
378
+ // 后续菜单/Function/AI 可以立即开始,确保速度优先。
379
+ if (options.enableTTS) {
380
+ const promptStartedAt = Date.now()
381
+ void speech.speakReceivedPromptAsync?.().then((started) => {
382
+ console.debug?.('[小园 TTS] 异步前置提示已触发,耗时', `${Date.now() - promptStartedAt}ms`, 'started:', started)
383
+ }).catch((error) => {
384
+ console.warn('[小园 TTS] 异步前置提示失败,不阻塞后续指令:', error?.message || error)
385
+ })
386
+ }
387
+
388
+ if (sessionId !== commandSessionId) return
389
+
390
+ try {
391
+ const result = await manager.run(commandText, {
392
+ // 执行过程中不播报中间状态,只保留字幕等待提示;每一步完成后再播报具体结果。
393
+ onBeforeStep: async ({ index, description, command }) => {
394
+ if (sessionId !== commandSessionId) return
395
+
396
+ // 每一个拆分后的步骤都必须遵循:
397
+ // 1. 先请求本步骤对应的 TTS 文案
398
+ // 2. TTS 真正进入 playing 后立即执行本步骤
399
+ // 3. 本步骤执行结束后,manager 再等待 3 秒后进入下一步骤
400
+ const stepSpeech = buildStepSpeech(command, description)
401
+ showSubtitle(stepSpeech, 0)
402
+
403
+ const stepTtsStartedAt = Date.now()
404
+ const stepPlayed = await speakWithRetry(stepSpeech, { providerOnly: true }, 2)
405
+ if (sessionId !== commandSessionId) return
406
+ if (stepPlayed === false) {
407
+ throw new Error(`第${Number(index) + 1}步 TTS 播放失败,已停止当前指令。`)
408
+ }
409
+
410
+ console.debug?.(
411
+ '[小园 TTS] 第',
412
+ Number(index) + 1,
413
+ '步音频已真正开始播放,立即执行:',
414
+ stepSpeech,
415
+ '耗时',
416
+ `${Date.now() - stepTtsStartedAt}ms`
417
+ )
418
+ },
419
+ onStep: ({ command, description, result }) => {
420
+ if (sessionId !== commandSessionId) return
421
+ // TTS 已在 onBeforeStep 中完成并于 playing 时放行执行。
422
+ // 这里不再重复播报,避免一个步骤播放两次。
423
+ if (command?.type === 'analysis') return
424
+ const success = result?.success !== false
425
+ const resultText = success
426
+ ? `已完成${description}。`
427
+ : `未能完成${description}。`
428
+ showSubtitle(resultText, 8000)
429
+ },
430
+ onAnalysisStart: ({ step }) => {
431
+ if (sessionId !== commandSessionId) return
432
+ const analysisText = normalizeAnalysisSubject(step?.instruction || '相关数据')
433
+ const promptText = `正在分析${analysisText},请稍等。`
434
+ // 分析步骤的“正在分析”播报已经在 onBeforeStep 中完成,这里只更新字幕。
435
+ // 避免同一句 TTS 重复请求/播放,减少延迟和语音竞争。
436
+ showSubtitle(promptText, 0)
437
+ },
438
+ onAnalysisDone: async ({ result: analysisResult }) => {
439
+ if (sessionId !== commandSessionId) return
440
+ const final = analysisResult?.analysis || analysisResult?.reply
441
+ if (final) {
442
+ const text = typeof final === 'string' ? final : JSON.stringify(final)
443
+ showSubtitle(text, 10000)
444
+ addMessage('assistant', text)
445
+
446
+ // AI 分析结果属于“最终结果播报”:必须把整段语音播完,
447
+ // manager 才允许进入下一步指令。这里不是只等 playing。
448
+ const completed = await speakWithRetry(text, { providerOnly: true, waitForEnd: true }, 2)
449
+ if (sessionId !== commandSessionId) return
450
+ if (completed === false) {
451
+ throw new Error('AI 分析结果播报失败,已停止后续步骤。')
452
+ }
453
+ }
454
+ }
455
+ })
456
+
457
+ const executionItems = Array.isArray(result?.execution) ? result.execution : []
458
+ const analysisItem = executionItems.find((item) => item?.type === 'analysis' && item?.data)
459
+
460
+ // 页面操作已经按步骤实时播报,不再追加“已完成相关操作”或重复播报。
461
+ // 分析结果已经由 onAnalysisDone 播报;没有任何执行结果时,才显示普通模型回复。
462
+ if (sessionId !== commandSessionId) return
463
+
464
+ if (!executionItems.length) {
465
+ const reply = String(result?.reply || '').trim()
466
+ if (reply && !/^(已完成处理|处理完成|收到指令,请您稍等[。!!]?|好的[。!!]?|收到[。!!]?)$/i.test(reply)) {
467
+ addMessage('assistant', reply)
468
+ showSubtitle(reply, 10000)
469
+ queueSpeak(reply)
470
+ }
471
+ } else if (!analysisItem && executionItems.some((item) => item.success === false)) {
472
+ const failedText = executionItems.find((item) => item.success === false)?.message || '当前操作未能完成。'
473
+ showSubtitle(failedText, 8000)
474
+ addMessage('assistant', failedText)
475
+ queueSpeak(failedText)
476
+ }
477
+ } catch (error) {
478
+ if (sessionId !== commandSessionId) return
479
+ const message = `处理失败:${error?.message || '未知错误'}`
480
+ addMessage('assistant', message)
481
+ showSubtitle(message, 8000)
482
+ queueSpeak(message)
483
+ } finally {
484
+ if (sessionId !== commandSessionId) return
485
+ processing.value = false
486
+ if (autoCollapseMs > 0) {
487
+ collapseTimer = window.setTimeout(collapse, autoCollapseMs)
488
+ }
489
+ }
490
+ }
491
+
492
+ function startWakeWord() {
493
+ if (!options.enableWakeWord || !speech.supported()) return
494
+ wakeController?.stop?.()
495
+ wakeController = speech.startWakeWordListener({
496
+ wakeWord,
497
+ onWake: () => activate('wake'),
498
+ onError: (code) => {
499
+ // 首次自动唤醒可能因为浏览器尚未授予麦克风权限而失败,不反复打断用户。
500
+ wakeListening.value = false
501
+ if (code === 'not-allowed' || code === 'service-not-allowed') {
502
+ showSubtitle('请先点击麦克风按钮授权语音,授权后可继续使用“你好小园”唤醒。', 7000)
503
+ }
504
+ }
505
+ })
506
+ wakeListening.value = true
507
+ }
508
+
509
+ onMounted(() => {
510
+ startWakeWord()
511
+ })
512
+
513
+ onBeforeUnmount(() => {
514
+ wakeController?.stop?.()
515
+ speech.stopSpeaking()
516
+ window.clearTimeout(collapseTimer)
517
+ window.clearTimeout(subtitleTimer)
518
+ })
519
+ </script>