diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx index 6e3fd01..89acecf 100644 --- a/apps/admin/src/main.tsx +++ b/apps/admin/src/main.tsx @@ -9,7 +9,7 @@ if (!root) throw new Error("找不到 #root 挂载点"); ReactDOM.createRoot(root).render( - + diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts index 3c72668..3212306 100644 --- a/apps/admin/vite.config.ts +++ b/apps/admin/vite.config.ts @@ -3,6 +3,7 @@ import react from "@vitejs/plugin-react"; import path from "path"; export default defineConfig({ + base: process.env.ADMIN_BASE ?? "/", plugins: [react()], resolve: { alias: { diff --git a/apps/api/src/ai/ai.controller.ts b/apps/api/src/ai/ai.controller.ts index 69ff273..b129663 100644 --- a/apps/api/src/ai/ai.controller.ts +++ b/apps/api/src/ai/ai.controller.ts @@ -3,6 +3,7 @@ import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { Response } from "express"; import { AiService } from "./ai.service"; import { ChatDto } from "./dto/chat.dto"; +import { TtsDto } from "./dto/tts.dto"; import { RateLimitGuard } from "../common/rate-limit.guard"; @ApiTags("ai") @@ -39,4 +40,18 @@ export class AiController { const suggestions = await this.ai.getSuggestions(dto); return { suggestions }; } + + @Post("tts") + @ApiOperation({ summary: "通义千问 TTS:将文本合成语音(返回音频)" }) + async tts(@Body() dto: TtsDto, @Res() res: Response): Promise { + try { + const { buffer, contentType } = await this.ai.synthesizeSpeech(dto.text, dto.voice); + res.setHeader("Content-Type", contentType); + res.setHeader("Cache-Control", "no-store"); + res.send(buffer); + } catch (err) { + const message = err instanceof Error ? err.message : "TTS 服务异常"; + res.status(502).json({ message }); + } + } } diff --git a/apps/api/src/ai/ai.service.ts b/apps/api/src/ai/ai.service.ts index cbbe418..ab5e0f0 100644 --- a/apps/api/src/ai/ai.service.ts +++ b/apps/api/src/ai/ai.service.ts @@ -50,6 +50,28 @@ export type ChatPersona = "artifact" | "guide" | "scholar" | "migration" | "repa export class AiService { private readonly logger = new Logger(AiService.name); + // 限制对 DashScope qwen-tts 的并发与节奏,避免触发账号级 QPS 限流(429 Throttling) + private static ttsActive = 0; + private static readonly ttsWaiters: (() => void)[] = []; + private static readonly TTS_MAX = Number(process.env["AI_TTS_CONCURRENCY"] ?? 1); + private static lastTtsAt = 0; + private static readonly TTS_MIN_GAP = Number(process.env["AI_TTS_MIN_GAP_MS"] ?? 300); + + private static async acquireTtsSlot(): Promise { + if (AiService.ttsActive < AiService.TTS_MAX) { + AiService.ttsActive++; + return; + } + await new Promise((resolve) => AiService.ttsWaiters.push(resolve)); + // 被唤醒即代表从释放者手中接过名额,ttsActive 保持不变 + } + + private static releaseTtsSlot(): void { + const next = AiService.ttsWaiters.shift(); + if (next) next(); + else AiService.ttsActive--; + } + constructor( private readonly config: ConfigService, private readonly db: DatabaseService @@ -251,6 +273,104 @@ ${common}`; return parseSuggestions(content); } + /** + * 通义千问 TTS:将文本合成语音。 + * 调用 DashScope qwen-tts,拿到临时音频 URL 后由后端拉取音频字节返回, + * 避免前端直连 OSS(跨域 / 过期)问题,实现同源音频。 + */ + async synthesizeSpeech( + text: string, + voice?: string + ): Promise<{ buffer: Buffer; contentType: string }> { + const apiKey = this.config.get("AI_API_KEY"); + if (!apiKey) throw new Error("AI 服务未配置:请在 .env 中设置 AI_API_KEY"); + + const model = this.config.get("AI_TTS_MODEL") ?? "cosyvoice-v3-flash"; + const v = voice || this.config.get("AI_TTS_VOICE") || "longxiaochun_v3"; + const clean = text.trim().slice(0, 800); + if (!clean) throw new Error("待合成文本为空"); + + const genUrl = + this.config.get("AI_TTS_URL") ?? + "https://dashscope.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer"; + + const audioUrl = await this.requestTtsUrl(genUrl, apiKey, model, v, clean); + + const audioResp = await fetch(audioUrl, { signal: AbortSignal.timeout(20_000) }); + if (!audioResp.ok) throw new Error(`音频下载失败 ${audioResp.status}`); + const contentType = audioResp.headers.get("content-type") ?? "audio/wav"; + const buffer = Buffer.from(await audioResp.arrayBuffer()); + return { buffer, contentType }; + } + + /** + * 请求 DashScope 生成语音并返回音频 URL。 + * 通过信号量限制并发,并对 429/5xx 做指数退避重试,规避账号级 QPS 限流。 + */ + private async requestTtsUrl( + genUrl: string, + apiKey: string, + model: string, + voice: string, + text: string + ): Promise { + const maxAttempts = 5; + let lastErr = "unknown"; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + await AiService.acquireTtsSlot(); + try { + // 节流:与上一次请求保持最小间隔,降低 QPS 峰值 + const since = Date.now() - AiService.lastTtsAt; + if (since < AiService.TTS_MIN_GAP) { + await new Promise((r) => setTimeout(r, AiService.TTS_MIN_GAP - since)); + } + AiService.lastTtsAt = Date.now(); + + const resp = await fetch(genUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + input: { text, voice, format: "mp3", sample_rate: 22050 }, + }), + signal: AbortSignal.timeout(25_000), + }); + + if (resp.status === 429 || resp.status >= 500) { + lastErr = `${resp.status}`; + // 触发限流/上游错误:退避后重试 + } else if (!resp.ok) { + const t = await resp.text().catch(() => ""); + throw new Error(`TTS 接口返回 ${resp.status}: ${t.slice(0, 200)}`); + } else { + const json = (await resp.json()) as { + output?: { audio?: { url?: string } }; + }; + const url = json?.output?.audio?.url; + if (url) return url; + lastErr = "no-url"; + } + } catch (err) { + // 网络/超时错误也重试 + lastErr = err instanceof Error ? err.message : "network"; + } finally { + AiService.releaseTtsSlot(); + } + + if (attempt < maxAttempts - 1) { + const delay = Math.min(500 * 2 ** attempt, 5000) + Math.floor(Math.random() * 300); + await new Promise((r) => setTimeout(r, delay)); + } + } + + this.logger.error(`TTS 重试仍失败:${lastErr}`); + throw new Error(`TTS 限流,请稍后再试(${lastErr})`); + } + private async chatComplete( messages: { role: string; content: string }[], temperature = 0.7 diff --git a/apps/api/src/ai/dto/tts.dto.ts b/apps/api/src/ai/dto/tts.dto.ts new file mode 100644 index 0000000..bfb01a4 --- /dev/null +++ b/apps/api/src/ai/dto/tts.dto.ts @@ -0,0 +1,14 @@ +import { IsOptional, IsString, MaxLength } from "class-validator"; + +export class TtsDto { + /** 待合成的纯文本(已去除 Markdown 标记) */ + @IsString() + @MaxLength(2000) + text!: string; + + /** 音色,默认 Cherry */ + @IsOptional() + @IsString() + @MaxLength(40) + voice?: string; +} diff --git a/apps/api/src/common/rate-limit.guard.ts b/apps/api/src/common/rate-limit.guard.ts index bb9474c..e0bb69b 100644 --- a/apps/api/src/common/rate-limit.guard.ts +++ b/apps/api/src/common/rate-limit.guard.ts @@ -14,7 +14,8 @@ import { Request } from "express"; @Injectable() export class RateLimitGuard implements CanActivate { private static readonly WINDOW_MS = 60_000; - private static readonly MAX_REQUESTS = 20; + // 单条回答会按句拆分为多次 TTS 调用,故放宽阈值(仍可防刷)。 + private static readonly MAX_REQUESTS = 120; private static readonly buckets = new Map(); canActivate(context: ExecutionContext): boolean { diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 1a0858c..4f5efdf 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -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 || "", diff --git a/apps/web/package.json b/apps/web/package.json index 964609f..04fb679 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/app/map/page.tsx b/apps/web/src/app/map/page.tsx index 302b3be..92ce5c1 100644 --- a/apps/web/src/app/map/page.tsx +++ b/apps/web/src/app/map/page.tsx @@ -407,7 +407,7 @@ export default function MapPage() {
-
+
文 @@ -514,7 +514,7 @@ export default function MapPage() {
)} {activeRoute && activeRoute.stops?.length > 0 && ( -
+
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 ? : } @@ -637,13 +637,13 @@ export default function MapPage() { -
{/* 角色设置 */} @@ -306,9 +418,25 @@ export default function ArtifactChat({ artifactId, artifactName, onConversationC
{m.content ? ( -
- {m.content} -
+ <> +
+ {m.content} +
+ {!(streaming && i === messages.length - 1) && ( + + )} + ) : ( @@ -378,7 +506,10 @@ export default function ArtifactChat({ artifactId, artifactName, onConversationC {streaming ? (