feat(tts): 新增通用流式 TTS 引擎并接入 AI 对话

- 新增 @wenwumap/tts 独立包:边流式边合成、按句排队顺序播放、
  专业 TTS 失败自动降级浏览器朗读,含 README 使用说明
- AI 后端新增 /ai/tts 接口,改用 DashScope CosyVoice(cosyvoice-v3-flash)
  输出 mp3,串行+退避重试规避 429 限流
- web 对话面板接入 SpeechQueue,按角色配音色,加语音开关与朗读按钮
- admin 支持 /admin/ 基路径部署
- 地图页移除大面积 backdrop-blur,降低 GPU 占用
This commit is contained in:
selfrelease
2026-06-14 23:13:26 +08:00
parent 3a55cd1978
commit 4a9397bccc
17 changed files with 955 additions and 26 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
transpilePackages: ["@wenwumap/shared"],
transpilePackages: ["@wenwumap/shared", "@wenwumap/tts"],
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:3002",
NEXT_PUBLIC_MAP_STYLE: process.env.NEXT_PUBLIC_MAP_STYLE || "",
+1
View File
@@ -17,6 +17,7 @@
"@radix-ui/react-tooltip": "^1.1.2",
"@vis.gl/react-google-maps": "^1.4.0",
"@wenwumap/shared": "workspace:*",
"@wenwumap/tts": "workspace:*",
"clsx": "^2.1.1",
"lucide-react": "^0.414.0",
"maplibre-gl": "^4.5.0",
+7 -7
View File
@@ -407,7 +407,7 @@ export default function MapPage() {
<div className="relative h-screen w-full overflow-hidden bg-[#090806] text-[#f6eddc]">
<div className="pointer-events-none absolute inset-0 z-0 bg-[radial-gradient(circle_at_top_left,rgba(190,137,55,0.18),transparent_32%),radial-gradient(circle_at_bottom_right,rgba(50,101,91,0.18),transparent_36%)]" />
<header className="absolute left-0 right-0 top-0 z-50 flex h-16 items-center border-b border-[#d6aa5b]/15 bg-[#080705]/92 px-6 shadow-[0_18px_40px_rgba(0,0,0,0.45)] backdrop-blur-xl">
<header className="absolute left-0 right-0 top-0 z-50 flex h-16 items-center border-b border-[#d6aa5b]/15 bg-[#080705] px-6 shadow-[0_18px_40px_rgba(0,0,0,0.45)]">
<div className="flex items-center gap-4">
<div className="flex h-9 w-9 items-center justify-center rounded-full border border-[#d6aa5b]/35 bg-[#d6aa5b]/10 text-sm text-[#f2cf83]">
@@ -514,7 +514,7 @@ export default function MapPage() {
</div>
)}
{activeRoute && activeRoute.stops?.length > 0 && (
<div className="absolute bottom-4 left-1/2 z-20 w-[min(720px,90%)] -translate-x-1/2 rounded-2xl border border-[#d6aa5b]/25 bg-[#0c0a06]/94 px-5 py-3.5 shadow-[0_12px_44px_rgba(0,0,0,0.55)] backdrop-blur-xl">
<div className="absolute bottom-4 left-1/2 z-20 w-[min(720px,90%)] -translate-x-1/2 rounded-2xl border border-[#d6aa5b]/25 bg-[#0c0a06] px-5 py-3.5 shadow-[0_12px_44px_rgba(0,0,0,0.55)]">
<div className="mb-2.5 flex items-center justify-between">
<span
className="flex items-center gap-2 font-serif text-sm font-semibold"
@@ -627,7 +627,7 @@ export default function MapPage() {
<button
onClick={() => setLeftCollapsed((v) => !v)}
title={leftCollapsed ? "展开左栏" : "收起左栏"}
className="absolute top-1/2 z-50 flex h-14 w-5 -translate-y-1/2 items-center justify-center rounded-r-md border border-l-0 border-[#d6aa5b]/20 bg-[#0c0a06]/90 text-sm text-[#d6aa5b] backdrop-blur transition-all duration-300 hover:bg-[#d6aa5b]/15 hover:text-[#f2cf83]"
className="absolute top-1/2 z-50 flex h-14 w-5 -translate-y-1/2 items-center justify-center rounded-r-md border border-l-0 border-[#d6aa5b]/20 bg-[#0c0a06] text-sm text-[#d6aa5b] transition-all duration-300 hover:bg-[#d6aa5b]/15 hover:text-[#f2cf83]"
style={{ left: leftW }}
>
{leftCollapsed ? <ChevronRight size={14} /> : <ChevronLeft size={14} />}
@@ -637,13 +637,13 @@ export default function MapPage() {
<button
onClick={() => setRightCollapsed((v) => !v)}
title={rightCollapsed ? "展开右栏" : "收起右栏"}
className="absolute top-1/2 z-50 flex h-14 w-5 -translate-y-1/2 items-center justify-center rounded-l-md border border-r-0 border-[#d6aa5b]/20 bg-[#0c0a06]/90 text-sm text-[#d6aa5b] backdrop-blur transition-all duration-300 hover:bg-[#d6aa5b]/15 hover:text-[#f2cf83]"
className="absolute top-1/2 z-50 flex h-14 w-5 -translate-y-1/2 items-center justify-center rounded-l-md border border-r-0 border-[#d6aa5b]/20 bg-[#0c0a06] text-sm text-[#d6aa5b] transition-all duration-300 hover:bg-[#d6aa5b]/15 hover:text-[#f2cf83]"
style={{ right: effRightW }}
>
{rightCollapsed ? <ChevronLeft size={14} /> : <ChevronRight size={14} />}
</button>
<aside className="absolute bottom-11 left-0 top-16 z-40 flex w-72 flex-col border-r border-[#d6aa5b]/15 bg-[#080705]/94 shadow-[18px_0_48px_rgba(0,0,0,0.38)] backdrop-blur-xl transition-transform duration-300" style={{ transform: leftCollapsed ? "translateX(-100%)" : "none" }}>
<aside className="absolute bottom-11 left-0 top-16 z-40 flex w-72 flex-col border-r border-[#d6aa5b]/15 bg-[#080705] shadow-[18px_0_48px_rgba(0,0,0,0.38)] transition-transform duration-300" style={{ transform: leftCollapsed ? "translateX(-100%)" : "none" }}>
<div className="shrink-0 border-b border-[#d6aa5b]/12 p-5">
<div className="text-xs uppercase tracking-[0.3em] text-[#8f8066]">Explore</div>
<h1 className="mt-2 font-serif text-xl font-semibold text-[#f6eddc]"></h1>
@@ -834,7 +834,7 @@ export default function MapPage() {
</div>
</aside>
<aside className="absolute bottom-11 right-0 top-16 z-40 border-l border-[#d6aa5b]/15 bg-[#080705]/94 shadow-[-18px_0_48px_rgba(0,0,0,0.38)] backdrop-blur-xl transition-transform duration-300" style={{ width: rightWidth, transform: rightCollapsed ? "translateX(100%)" : "none" }}>
<aside className="absolute bottom-11 right-0 top-16 z-40 border-l border-[#d6aa5b]/15 bg-[#080705] shadow-[-18px_0_48px_rgba(0,0,0,0.38)] transition-transform duration-300" style={{ width: rightWidth, transform: rightCollapsed ? "translateX(100%)" : "none" }}>
{/* 拖拽手柄:调整右栏宽度 */}
<div
onMouseDown={startResize}
@@ -1111,7 +1111,7 @@ export default function MapPage() {
)}
</aside>
<footer className="absolute bottom-0 left-0 right-0 z-50 flex h-11 items-center border-t border-[#d6aa5b]/12 bg-[#080705]/95 px-6 text-xs text-[#8f8066] backdrop-blur-xl">
<footer className="absolute bottom-0 left-0 right-0 z-50 flex h-11 items-center border-t border-[#d6aa5b]/12 bg-[#080705] px-6 text-xs text-[#8f8066]">
<span className="text-[#c8b88e]"> {groupedPoints.length} {zoom <= 7 ? "个城市" : "个机构"}{institutionCount} · {visiblePoints.length} </span>
{filterCity && <span className="ml-4">{filterCity}</span>}
{filterCategory && <span className="ml-4">{CATEGORY_LABELS[filterCategory]}</span>}
+146 -15
View File
@@ -3,7 +3,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Sparkles, ChevronRight } from "lucide-react";
import { Sparkles, ChevronRight, Volume2, VolumeX, Square } from "lucide-react";
import { SpeechQueue } from "@wenwumap/tts";
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3002";
@@ -40,6 +41,16 @@ interface ArtifactChatProps {
fill?: boolean;
}
// 各角色对应的 CosyVoice 音色
const PERSONA_VOICE: Record<Persona, string> = {
artifact: "longxiaochun_v3", // 温润中性
guide: "longxiaochun_v3", // 亲切讲解
scholar: "longsanshu_v3", // 沉稳学者
migration: "longze_v3", // 深沉叙事
repatriation: "longanzhi_v3", // 温情
youth: "longwan_v3", // 活泼
};
export default function ArtifactChat({ artifactId, artifactName, onConversationChange, fill }: ArtifactChatProps) {
const [persona, setPersona] = useState<Persona>("artifact");
const [messages, setMessages] = useState<ChatMessage[]>([]);
@@ -52,6 +63,59 @@ export default function ArtifactChat({ artifactId, artifactName, onConversationC
const scrollRef = useRef<HTMLDivElement | null>(null);
const storageKey = `wenwu_chat_${artifactId}_${persona}`;
// ===== TTS(通义千问语音合成 · 边流式边合成的播放队列)=====
const [ttsOn, setTtsOn] = useState(true);
const [speakingIdx, setSpeakingIdx] = useState<number | null>(null);
const queueRef = useRef<SpeechQueue | null>(null);
// 读取本地保存的“语音开关”偏好
useEffect(() => {
try {
const saved = localStorage.getItem("wenwu_tts_on");
if (saved !== null) setTtsOn(saved === "1");
} catch {
/* ignore */
}
}, []);
// 懒初始化通用 TTS 播放引擎(@wenwumap/tts
const getQueue = useCallback(() => {
if (!queueRef.current) {
queueRef.current = new SpeechQueue({
endpoint: `${API_URL}/api/v1/ai/tts`,
voice: PERSONA_VOICE.artifact,
lang: "zh-CN",
onSpeakingChange: (tag) => setSpeakingIdx(typeof tag === "number" ? tag : null),
});
}
return queueRef.current;
}, []);
const ttsUnlock = useCallback(() => getQueue().unlock(), [getQueue]);
const ttsStop = useCallback(() => queueRef.current?.stop(), []);
const ttsBegin = useCallback(
(idx: number) => {
const q = getQueue();
q.setVoice(PERSONA_VOICE[persona] ?? PERSONA_VOICE.artifact);
q.begin(idx);
},
[getQueue, persona]
);
const ttsFeed = useCallback((delta: string) => queueRef.current?.feed(delta), []);
const ttsFlush = useCallback(() => queueRef.current?.flush(), []);
const speakWhole = useCallback(
(text: string, idx: number) => {
const q = getQueue();
q.unlock();
q.setVoice(PERSONA_VOICE[persona] ?? PERSONA_VOICE.artifact);
q.speakWhole(text, idx);
},
[getQueue, persona]
);
// 卸载时释放
useEffect(() => () => queueRef.current?.destroy(), []);
const persist = useCallback(
(msgs: ChatMessage[]) => {
try {
@@ -67,6 +131,7 @@ export default function ArtifactChat({ artifactId, artifactName, onConversationC
// 切换文物或角色时:从本地存储恢复该组合的历史对话
useEffect(() => {
abortRef.current?.abort();
ttsStop();
setError(null);
setStreaming(false);
setSuggestions([]);
@@ -128,6 +193,14 @@ export default function ArtifactChat({ artifactId, artifactName, onConversationC
const content = text.trim();
if (!content || streaming) return;
const speakThis = ttsOn;
const assistantIdx = messages.length + 1; // 本轮 assistant 消息在列表中的下标
// 在用户手势内解锁自动播放,并开启朗读会话
if (speakThis) {
ttsUnlock();
ttsBegin(assistantIdx);
}
const history: ChatMessage[] = [...messages, { role: "user", content }];
setMessages([...history, { role: "assistant", content: "" }]);
setInput("");
@@ -176,6 +249,7 @@ export default function ArtifactChat({ artifactId, artifactName, onConversationC
setError(json.error);
} else if (json.t) {
assistantContent += json.t;
if (speakThis) ttsFeed(json.t);
setMessages((prev) => {
const next = [...prev];
const last = next[next.length - 1];
@@ -199,6 +273,7 @@ export default function ArtifactChat({ artifactId, artifactName, onConversationC
} finally {
setStreaming(false);
abortRef.current = null;
if (speakThis) ttsFlush();
}
// 每轮回答后生成 4 个下一步追问
@@ -211,7 +286,19 @@ export default function ArtifactChat({ artifactId, artifactName, onConversationC
void fetchSuggestions(finalHistory);
}
},
[artifactId, persona, messages, streaming, fetchSuggestions, persist]
[
artifactId,
persona,
messages,
streaming,
fetchSuggestions,
persist,
ttsOn,
ttsUnlock,
ttsBegin,
ttsFeed,
ttsFlush,
]
);
return (
@@ -227,20 +314,45 @@ export default function ArtifactChat({ artifactId, artifactName, onConversationC
</span>
<span className="text-[11px] uppercase tracking-[0.24em] text-[#a99566]"></span>
</div>
{messages.length > 0 && (
<div className="flex items-center gap-3">
<button
onClick={() => {
abortRef.current?.abort();
setMessages([]);
setError(null);
setSuggestions([]);
persist([]);
setTtsOn((prev) => {
const next = !prev;
try {
localStorage.setItem("wenwu_tts_on", next ? "1" : "0");
} catch {
/* ignore */
}
if (next) ttsUnlock();
else ttsStop();
return next;
});
}}
className="text-[11px] text-[#8f8066] transition hover:text-[#f2cf83]"
title={ttsOn ? "语音朗读:开(点击关闭)" : "语音朗读:关(点击开启)"}
className={`flex items-center gap-1 text-[11px] transition ${
ttsOn ? "text-[#f2cf83]" : "text-[#8f8066] hover:text-[#f2cf83]"
}`}
>
{ttsOn ? <Volume2 size={14} /> : <VolumeX size={14} />}
<span></span>
</button>
)}
{messages.length > 0 && (
<button
onClick={() => {
abortRef.current?.abort();
ttsStop();
setMessages([]);
setError(null);
setSuggestions([]);
persist([]);
}}
className="text-[11px] text-[#8f8066] transition hover:text-[#f2cf83]"
>
</button>
)}
</div>
</div>
{/* 角色设置 */}
@@ -306,9 +418,25 @@ export default function ArtifactChat({ artifactId, artifactName, onConversationC
<div key={i} className="flex justify-start">
<div className="max-w-[92%] rounded-2xl rounded-bl-sm border border-[#d6aa5b]/14 bg-[#11100d]/80 px-3 py-2 text-[#ecdfc6]">
{m.content ? (
<div className="md-chat">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{m.content}</ReactMarkdown>
</div>
<>
<div className="md-chat">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{m.content}</ReactMarkdown>
</div>
{!(streaming && i === messages.length - 1) && (
<button
onClick={() => {
ttsUnlock();
if (speakingIdx === i) ttsStop();
else speakWhole(m.content, i);
}}
title={speakingIdx === i ? "停止朗读" : "朗读这段"}
className="mt-1.5 inline-flex items-center gap-1 text-[10px] text-[#8f8066] transition hover:text-[#f2cf83]"
>
{speakingIdx === i ? <Square size={11} /> : <Volume2 size={12} />}
<span>{speakingIdx === i ? "朗读中 · 停止" : "朗读"}</span>
</button>
)}
</>
) : (
<span className="inline-flex gap-1 py-1 align-middle">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[#d6aa5b] [animation-delay:-0.2s]" />
@@ -378,7 +506,10 @@ export default function ArtifactChat({ artifactId, artifactName, onConversationC
{streaming ? (
<button
type="button"
onClick={() => abortRef.current?.abort()}
onClick={() => {
abortRef.current?.abort();
ttsStop();
}}
className="flex-shrink-0 rounded-full border border-[#d6aa5b]/30 px-3 py-2 text-xs text-[#f2cf83] transition hover:bg-[#d6aa5b]/12"
>
+2 -1
View File
@@ -16,7 +16,8 @@
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"],
"@wenwumap/shared": ["../../packages/shared/src/index.ts"]
"@wenwumap/shared": ["../../packages/shared/src/index.ts"],
"@wenwumap/tts": ["../../packages/tts/src/index.ts"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],