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:
2026-05-19 12:01:09 +08:00
parent 78e3f450c2
commit bcf4d4e621
69 changed files with 14599 additions and 150 deletions
@@ -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,
};
}