feat: 第五轮开发 - 14项未来路线图功能完整实现
W1-W14 全部完成: - W1: 消息搜索 (ILIKE全文检索 + SearchModal) - W2: 对话导出 (JSON/Markdown/TXT三格式) - W3: 记忆时间线 DevTools 可视化 - W4: 通知推送系统 (WebSocket + Browser Notification API) - W5: 定时提醒 (30s轮询 + 重复提醒 + WebSocket推送) - W6: 每日简报 (08:00自动生成: 天气+新闻+提醒+AI摘要) - W7: IoT场景自动化 (规则引擎 10s轮询 + 条件评估 + 场景执行) - W8: 语音输入 (浏览器 Speech Recognition API) - W9: STT服务 (voice-service + whisper.cpp) - W10: TTS服务 (浏览器 Speech Synthesis + edge-tts三档回退) - W11: 文件管理 (上传/下载/缩略图/纯Go bilinear缩放) - W12: 知识库RAG (PostgreSQL tsvector + 文档分块 + 检索) - W13: 多模态 (图片上传+分析: Vision API + 本地Go分析回退) - W14: PWA (Service Worker + 离线页 + install prompt) 总计: 6个Go微服务 + 10+前端组件 + 10+ PostgreSQL表 + 4个后台调度器
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
export interface SpeakOptions {
|
||||
rate?: number; // 语速 0.1-10, 默认 1
|
||||
pitch?: number; // 音调 0-2, 默认 1
|
||||
volume?: number; // 音量 0-1, 默认 1
|
||||
voice?: SpeechSynthesisVoice;
|
||||
lang?: string; // 默认 zh-CN
|
||||
}
|
||||
|
||||
export interface UseSpeechSynthesisReturn {
|
||||
isSpeaking: boolean;
|
||||
isSupported: boolean;
|
||||
isPaused: boolean;
|
||||
voices: SpeechSynthesisVoice[];
|
||||
currentVoice: SpeechSynthesisVoice | null;
|
||||
speak: (text: string, options?: SpeakOptions) => void;
|
||||
stop: () => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
setVoice: (voice: SpeechSynthesisVoice) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用的中文语音列表
|
||||
*/
|
||||
export function getChineseVoices(): SpeechSynthesisVoice[] {
|
||||
if (typeof window === 'undefined' || !window.speechSynthesis) {
|
||||
return [];
|
||||
}
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
return voices.filter(
|
||||
(v) =>
|
||||
v.lang.startsWith('zh-CN') ||
|
||||
v.lang.startsWith('zh-TW') ||
|
||||
v.lang.startsWith('zh-HK') ||
|
||||
v.lang.startsWith('zh-'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最佳中文语音 — 优先选择包含 "Xiaoxiao" 的 (自然度最高)
|
||||
*/
|
||||
export function getBestChineseVoice(): SpeechSynthesisVoice | null {
|
||||
const chineseVoices = getChineseVoices();
|
||||
if (chineseVoices.length === 0) return null;
|
||||
|
||||
// 优先匹配包含 "Xiaoxiao" 的语音
|
||||
const xiaoxiao = chineseVoices.find((v) => v.name.includes('Xiaoxiao'));
|
||||
if (xiaoxiao) return xiaoxiao;
|
||||
|
||||
// 其次匹配 "Yunxi"、"Xiaoyi"
|
||||
const yunxi = chineseVoices.find((v) => v.name.includes('Yunxi'));
|
||||
if (yunxi) return yunxi;
|
||||
|
||||
const xiaoyi = chineseVoices.find((v) => v.name.includes('Xiaoyi'));
|
||||
if (xiaoyi) return xiaoyi;
|
||||
|
||||
// fallback 到第一个中文语音
|
||||
return chineseVoices[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 将长文本按句号、换行分割成段落,避免浏览器截断
|
||||
*/
|
||||
function splitTextIntoChunks(text: string, maxChunkLength: number = 200): string[] {
|
||||
const chunks: string[] = [];
|
||||
// 按句号、感叹号、问号、换行分割
|
||||
const sentences = text.split(/(?<=[。!?!?\n])/g);
|
||||
|
||||
let current = '';
|
||||
for (const sentence of sentences) {
|
||||
if (current.length + sentence.length > maxChunkLength && current.length > 0) {
|
||||
chunks.push(current.trim());
|
||||
current = '';
|
||||
}
|
||||
current += sentence;
|
||||
}
|
||||
if (current.trim()) {
|
||||
chunks.push(current.trim());
|
||||
}
|
||||
return chunks.length > 0 ? chunks : [text];
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器 Speech Synthesis TTS Hook
|
||||
*
|
||||
* 使用浏览器原生 Speech Synthesis API 进行文字转语音。
|
||||
* - 中文语音自动优选
|
||||
* - 长文本自动分段
|
||||
* - Chrome 暂停 bug 规避(定期 resume)
|
||||
*/
|
||||
export function useSpeechSynthesis(): UseSpeechSynthesisReturn {
|
||||
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]);
|
||||
const [currentVoice, setCurrentVoice] = useState<SpeechSynthesisVoice | null>(null);
|
||||
|
||||
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
|
||||
const chunksRef = useRef<string[]>([]);
|
||||
const chunkIndexRef = useRef(0);
|
||||
const optionsRef = useRef<SpeakOptions>({});
|
||||
const resumeIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const isSupported =
|
||||
typeof window !== 'undefined' && 'speechSynthesis' in window;
|
||||
|
||||
// 加载语音列表
|
||||
useEffect(() => {
|
||||
if (!isSupported) return;
|
||||
|
||||
const loadVoices = () => {
|
||||
const available = window.speechSynthesis.getVoices();
|
||||
if (available.length > 0) {
|
||||
setVoices(available);
|
||||
// 自动选择最佳中文语音
|
||||
const best = getBestChineseVoice();
|
||||
if (best) {
|
||||
setCurrentVoice(best);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadVoices();
|
||||
window.speechSynthesis.onvoiceschanged = loadVoices;
|
||||
|
||||
return () => {
|
||||
window.speechSynthesis.onvoiceschanged = null;
|
||||
};
|
||||
}, [isSupported]);
|
||||
|
||||
// Chrome bug 规避:定期 resume 避免长时间不调用后暂停
|
||||
useEffect(() => {
|
||||
if (isSpeaking && !isPaused) {
|
||||
resumeIntervalRef.current = setInterval(() => {
|
||||
if (window.speechSynthesis.speaking && window.speechSynthesis.paused) {
|
||||
window.speechSynthesis.resume();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (resumeIntervalRef.current) {
|
||||
clearInterval(resumeIntervalRef.current);
|
||||
resumeIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isSpeaking, isPaused]);
|
||||
|
||||
/** 朗读下一段 */
|
||||
const speakNextChunk = useCallback(() => {
|
||||
const chunks = chunksRef.current;
|
||||
const idx = chunkIndexRef.current;
|
||||
|
||||
if (idx >= chunks.length) {
|
||||
// 全部读完
|
||||
setIsSpeaking(false);
|
||||
setIsPaused(false);
|
||||
utteranceRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(chunks[idx]);
|
||||
const opts = optionsRef.current;
|
||||
|
||||
utterance.rate = opts.rate ?? 1;
|
||||
utterance.pitch = opts.pitch ?? 1;
|
||||
utterance.volume = opts.volume ?? 1;
|
||||
utterance.lang = opts.lang ?? 'zh-CN';
|
||||
|
||||
if (opts.voice) {
|
||||
utterance.voice = opts.voice;
|
||||
} else if (currentVoice) {
|
||||
utterance.voice = currentVoice;
|
||||
}
|
||||
|
||||
utterance.onend = () => {
|
||||
chunkIndexRef.current++;
|
||||
speakNextChunk();
|
||||
};
|
||||
|
||||
utterance.onerror = (e) => {
|
||||
// 'canceled' 是正常取消,不报错
|
||||
if (e.error !== 'canceled' && e.error !== 'interrupted') {
|
||||
console.warn('[TTS] SpeechSynthesis error:', e.error);
|
||||
}
|
||||
setIsSpeaking(false);
|
||||
setIsPaused(false);
|
||||
utteranceRef.current = null;
|
||||
};
|
||||
|
||||
utterance.onpause = () => setIsPaused(true);
|
||||
utterance.onresume = () => setIsPaused(false);
|
||||
|
||||
utteranceRef.current = utterance;
|
||||
window.speechSynthesis.speak(utterance);
|
||||
}, [currentVoice]);
|
||||
|
||||
/** 开始朗读 */
|
||||
const speak = useCallback(
|
||||
(text: string, options?: SpeakOptions) => {
|
||||
if (!isSupported || !text.trim()) return;
|
||||
|
||||
// 先停止当前朗读
|
||||
window.speechSynthesis.cancel();
|
||||
|
||||
// 分段
|
||||
const chunks = splitTextIntoChunks(text);
|
||||
chunksRef.current = chunks;
|
||||
chunkIndexRef.current = 0;
|
||||
optionsRef.current = options ?? {};
|
||||
|
||||
setIsSpeaking(true);
|
||||
setIsPaused(false);
|
||||
|
||||
// 延迟一帧确保 cancel 生效
|
||||
setTimeout(() => speakNextChunk(), 50);
|
||||
},
|
||||
[isSupported, speakNextChunk],
|
||||
);
|
||||
|
||||
/** 停止朗读 */
|
||||
const stop = useCallback(() => {
|
||||
window.speechSynthesis.cancel();
|
||||
setIsSpeaking(false);
|
||||
setIsPaused(false);
|
||||
utteranceRef.current = null;
|
||||
chunksRef.current = [];
|
||||
chunkIndexRef.current = 0;
|
||||
}, []);
|
||||
|
||||
/** 暂停 */
|
||||
const pause = useCallback(() => {
|
||||
if (isSpeaking && !isPaused) {
|
||||
window.speechSynthesis.pause();
|
||||
setIsPaused(true);
|
||||
}
|
||||
}, [isSpeaking, isPaused]);
|
||||
|
||||
/** 恢复 */
|
||||
const resume = useCallback(() => {
|
||||
if (isPaused) {
|
||||
window.speechSynthesis.resume();
|
||||
setIsPaused(false);
|
||||
}
|
||||
}, [isPaused]);
|
||||
|
||||
/** 设置语音 */
|
||||
const setVoice = useCallback((voice: SpeechSynthesisVoice) => {
|
||||
setCurrentVoice(voice);
|
||||
}, []);
|
||||
|
||||
// 组件卸载时停止
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.speechSynthesis.cancel();
|
||||
if (resumeIntervalRef.current) {
|
||||
clearInterval(resumeIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isSpeaking,
|
||||
isSupported,
|
||||
isPaused,
|
||||
voices,
|
||||
currentVoice,
|
||||
speak,
|
||||
stop,
|
||||
pause,
|
||||
resume,
|
||||
setVoice,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user