feat(wechat): 关注公众号后自动回复欢迎语(被动回复,可后台配置) #12

Merged
Blizzard merged 42 commits from feat/site into main 2026-07-22 07:24:18 +00:00
8 changed files with 64 additions and 11 deletions
Showing only changes of commit b6927247da - Show all commits
@@ -14,6 +14,7 @@ export interface VoiceCallbacks {
onState?: (s: VoiceState) => void; onState?: (s: VoiceState) => void;
onTranscript?: (text: string, final: boolean) => void; onTranscript?: (text: string, final: boolean) => void;
onTask?: (taskId: string) => void; onTask?: (taskId: string) => void;
onReply?: (deltaText: string) => void; // Agent 回答增量文本(打字机,逐 token)
onError?: (msg: string) => void; onError?: (msg: string) => void;
} }
@@ -115,6 +116,9 @@ export class VoiceClient {
case "task": case "task":
if (m.task_id) this.cb.onTask?.(m.task_id); if (m.task_id) this.cb.onTask?.(m.task_id);
break; break;
case "reply":
if (m.text) this.cb.onReply?.(m.text); // 打字机:回答增量文本
break;
case "speaking": case "speaking":
this.setState("speaking"); this.setState("speaking");
break; break;
@@ -24,8 +24,9 @@ export function VoiceDock({ onTask }: Props) {
const toast = useToast(); const toast = useToast();
const clientRef = useRef<VoiceClient | null>(null); const clientRef = useRef<VoiceClient | null>(null);
const [state, setState] = useState<VoiceState>("idle"); const [state, setState] = useState<VoiceState>("idle");
const [transcript, setTranscript] = useState(""); const [transcript, setTranscript] = useState(""); // 我说的(ASR 转写)
const [open, setOpen] = useState(false); // 是否展开转写气泡 const [reply, setReply] = useState(""); // JARVIS 回答(打字机,逐 token 累加)
const [open, setOpen] = useState(false); // 是否展开对话气泡
// 懒建客户端(首次点按时,带上用户手势→AudioContext 才能启动)。 // 懒建客户端(首次点按时,带上用户手势→AudioContext 才能启动)。
const ensureClient = useCallback((): VoiceClient => { const ensureClient = useCallback((): VoiceClient => {
@@ -38,7 +39,11 @@ export function VoiceDock({ onTask }: Props) {
}, },
onTask: (taskId) => { onTask: (taskId) => {
onTask(taskId); onTask(taskId);
setOpen(false); setOpen(true); // 留着气泡显示打字机回答
},
onReply: (delta) => {
setReply((r) => r + delta); // 打字机:增量拼接,LLM 首 token 即刻可见
setOpen(true);
}, },
onError: (msg) => toast.push("error", msg), onError: (msg) => toast.push("error", msg),
}); });
@@ -55,6 +60,7 @@ export function VoiceDock({ onTask }: Props) {
c.stopListening(); c.stopListening();
} else { } else {
setTranscript(""); setTranscript("");
setReply(""); // 新一轮:清上一轮的回答
setOpen(true); setOpen(true);
await c.startListening(); // speaking 中会先打断再开新一轮 await c.startListening(); // speaking 中会先打断再开新一轮
} }
@@ -68,11 +74,25 @@ export function VoiceDock({ onTask }: Props) {
return ( return (
<div className="pointer-events-none fixed bottom-6 right-6 z-40 flex flex-col items-end gap-2"> <div className="pointer-events-none fixed bottom-6 right-6 z-40 flex flex-col items-end gap-2">
{/* 转写气泡 */} {/* 对话气泡:我说的(转写)+ JARVIS 回答(打字机) */}
{open && transcript && ( {open && (transcript || reply) && (
<div className="pointer-events-auto max-w-xs rounded-2xl border border-line bg-ink-850/95 px-4 py-2.5 text-sm text-slate-200 shadow-xl backdrop-blur"> <div className="pointer-events-auto max-w-xs rounded-2xl border border-line bg-ink-850/95 px-4 py-3 text-sm shadow-xl backdrop-blur">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<span className="flex-1 leading-relaxed">{transcript}</span> <div className="flex-1 space-y-1.5">
{transcript && (
<p className="leading-relaxed text-slate-400">
<span className="mr-1 text-[11px] text-slate-500"></span>
{transcript}
</p>
)}
{reply && (
<p className="leading-relaxed text-slate-100">
<span className="mr-1 text-[11px] text-brand-300">JARVIS</span>
{reply}
{state === "thinking" && <span className="ml-0.5 inline-block h-3.5 w-[2px] translate-y-[2px] animate-pulse bg-brand-300 align-middle" />}
</p>
)}
</div>
<button className="mt-0.5 text-slate-500 hover:text-slate-300" onClick={() => setOpen(false)} aria-label="收起"> <button className="mt-0.5 text-slate-500 hover:text-slate-300" onClick={() => setOpen(false)} aria-label="收起">
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </button>
+13 -3
View File
@@ -69,7 +69,8 @@ func main() {
answer := make([]byte, 0, 1<<20) answer := make([]byte, 0, 1<<20)
done := make(chan struct{}) done := make(chan struct{})
var endAt, tTask, tFirstAudio, tDone time.Time // 时延测量:以"说完(ClientEnd)"为起点 var endAt, tTask, tFirstReply, tFirstAudio, tDone time.Time // 时延测量:以"说完(ClientEnd)"为起点
var replyText string
go func() { // 读循环:文本帧=事件,二进制帧=回答 TTS 音频 go func() { // 读循环:文本帧=事件,二进制帧=回答 TTS 音频
defer close(done) defer close(done)
for { for {
@@ -100,6 +101,12 @@ func main() {
case voice.ServerTask: case voice.ServerTask:
tTask = time.Now() tTask = time.Now()
fmt.Printf(" ← 任务已提交 task_id=%sAgent 正在思考…)\n", m.TaskID) fmt.Printf(" ← 任务已提交 task_id=%sAgent 正在思考…)\n", m.TaskID)
case voice.ServerReply:
if tFirstReply.IsZero() {
tFirstReply = time.Now()
fmt.Println(" ← 💬 打字机开始(回答文字逐字冒出)…")
}
replyText += m.Text
case voice.ServerSpeaking: case voice.ServerSpeaking:
fmt.Println(" ← Agent 开始朗读回答…") fmt.Println(" ← Agent 开始朗读回答…")
case voice.ServerTTSEnd: case voice.ServerTTSEnd:
@@ -141,13 +148,16 @@ func main() {
fmt.Printf("\n🔊 回答音频 %d 字节(时长 %.1f 秒)→ 存 sim_answer.wav\n", len(answer), dur) fmt.Printf("\n🔊 回答音频 %d 字节(时长 %.1f 秒)→ 存 sim_answer.wav\n", len(answer), dur)
fmt.Println(" afplay sim_answer.wav # 听 JARVIS 的语音回答") fmt.Println(" afplay sim_answer.wav # 听 JARVIS 的语音回答")
// 时延拆解(以"说完"为 0 点)。首字延迟 = 听到第一声的时间,是体感关键。 // 时延拆解(以"说完"为 0 点)。
fmt.Println("\n⏱️ 时延拆解(从「说完」起算):") fmt.Println("\n⏱️ 时延拆解(从「说完」起算):")
if !tTask.IsZero() { if !tTask.IsZero() {
fmt.Printf(" · 提交任务 %.2fs(含 ClientEnd 后 0.5s 兜底等待)\n", tTask.Sub(endAt).Seconds()) fmt.Printf(" · 提交任务 %.2fs(含 ClientEnd 后 0.5s 兜底等待)\n", tTask.Sub(endAt).Seconds())
} }
if !tFirstReply.IsZero() {
fmt.Printf(" · 💬 首字上屏 %.2fs ← 打字机开始(体感「马上响应」就看这个)\n", tFirstReply.Sub(endAt).Seconds())
}
if !tFirstAudio.IsZero() { if !tFirstAudio.IsZero() {
fmt.Printf(" · 👂 首字出声 %.2fs ← 体感响应速度就看这个\n", tFirstAudio.Sub(endAt).Seconds()) fmt.Printf(" · 👂 首字出声 %.2fs ← 听到第一声\n", tFirstAudio.Sub(endAt).Seconds())
} }
if !tDone.IsZero() { if !tDone.IsZero() {
fmt.Printf(" · 朗读完毕 %.2fs= 首字 %.2fs + 念完 %.1fs 那段话)\n", fmt.Printf(" · 朗读完毕 %.2fs= 首字 %.2fs + 念完 %.1fs 那段话)\n",
@@ -39,6 +39,9 @@ func (s *voiceSession) speak(taskID string) {
unsub, err := s.h.bus.SubscribeTokens(taskID, unsub, err := s.h.bus.SubscribeTokens(taskID,
func(tok []byte) { func(tok []byte) {
// 打字机:每个 token 一到就转发给客户端显示(早于音频,LLM 首 token 即刻反馈)。
s.send(voice.ServerMsg{Type: voice.ServerReply, Text: string(tok)})
// 同时攒句喂 TTS(成句即合成,音频随后跟上)。
for _, sentence := range sb.Push(string(tok)) { for _, sentence := range sb.Push(string(tok)) {
push(sentence) push(sentence)
} }
@@ -28,6 +28,7 @@ const (
type ServerMsg struct { type ServerMsg struct {
Type string `json:"type"` Type string `json:"type"`
// transcriptASR 转写(Final=false 为实时部分结果,true 为最终) // transcriptASR 转写(Final=false 为实时部分结果,true 为最终)
// reply:Agent 回答的增量文本(打字机效果,逐 token 下发,早于音频)
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
Final bool `json:"final,omitempty"` Final bool `json:"final,omitempty"`
// task:转写完成、任务已提交,带 task_id 供客户端切运行视图 // task:转写完成、任务已提交,带 task_id 供客户端切运行视图
@@ -41,6 +42,7 @@ const (
ServerReady = "ready" // 会话就绪,可以开始说话 ServerReady = "ready" // 会话就绪,可以开始说话
ServerTranscript = "transcript" // ASR 转写结果(部分/最终) ServerTranscript = "transcript" // ASR 转写结果(部分/最终)
ServerTask = "task" // 任务已提交(带 task_id ServerTask = "task" // 任务已提交(带 task_id
ServerReply = "reply" // Agent 回答增量文本(打字机;逐 token,早于音频)
ServerSpeaking = "speaking" // Agent 开始出声(首段 TTS 音频将至) ServerSpeaking = "speaking" // Agent 开始出声(首段 TTS 音频将至)
ServerTTSEnd = "tts_end" // 本轮 TTS 播放完毕 ServerTTSEnd = "tts_end" // 本轮 TTS 播放完毕
ServerError = "error" // 出错 ServerError = "error" // 出错
@@ -7,6 +7,10 @@ import "strings"
// 攒句阈值:从句标点处断句前,至少要攒够这么多 rune,避免"你好,"这种半截就吐给 TTS。 // 攒句阈值:从句标点处断句前,至少要攒够这么多 rune,避免"你好,"这种半截就吐给 TTS。
const defaultMinClause = 12 const defaultMinClause = 12
// 首块阈值:本轮**第一次**出声用更低的门槛,让首字尽快合成、尽快听见(抢首字延迟)。
// 之后回到 defaultMinClause 保后续朗读顺畅、不碎。
const firstClauseMin = 5
// 句末标点:命中即成一句吐给 TTS。 // 句末标点:命中即成一句吐给 TTS。
func isSentenceEnd(r rune) bool { func isSentenceEnd(r rune) bool {
switch r { switch r {
@@ -31,11 +35,20 @@ func isClauseEnd(r rune) bool {
type SentenceBuffer struct { type SentenceBuffer struct {
buf strings.Builder buf strings.Builder
minClause int minClause int
emitted bool // 本轮是否已出过第一句(决定用首块低阈值还是常规阈值)
} }
// NewSentenceBuffer 建一个默认阈值的攒句器。 // NewSentenceBuffer 建一个默认阈值的攒句器。
func NewSentenceBuffer() *SentenceBuffer { return &SentenceBuffer{minClause: defaultMinClause} } func NewSentenceBuffer() *SentenceBuffer { return &SentenceBuffer{minClause: defaultMinClause} }
// clauseThreshold 首句用低阈值抢首字延迟,之后回常规阈值。
func (b *SentenceBuffer) clauseThreshold() int {
if !b.emitted {
return firstClauseMin
}
return b.minClause
}
// Push 追加一段 token 文本,返回本次可以立即吐给 TTS 的完整句子(0..N 句,已 Trim 两端空白)。 // Push 追加一段 token 文本,返回本次可以立即吐给 TTS 的完整句子(0..N 句,已 Trim 两端空白)。
// 未成句的尾巴留在内部缓冲,等后续 token 或 Flush。 // 未成句的尾巴留在内部缓冲,等后续 token 或 Flush。
func (b *SentenceBuffer) Push(text string) []string { func (b *SentenceBuffer) Push(text string) []string {
@@ -47,12 +60,13 @@ func (b *SentenceBuffer) Push(text string) []string {
cut := false cut := false
if isSentenceEnd(r) { if isSentenceEnd(r) {
cut = true cut = true
} else if isClauseEnd(r) && (i+1-lastCut) >= b.minClause { } else if isClauseEnd(r) && (i+1-lastCut) >= b.clauseThreshold() {
cut = true cut = true
} }
if cut { if cut {
if s := strings.TrimSpace(string(runes[lastCut : i+1])); s != "" { if s := strings.TrimSpace(string(runes[lastCut : i+1])); s != "" {
out = append(out, s) out = append(out, s)
b.emitted = true // 出过一句后,后续回常规阈值(不碎)
} }
lastCut = i + 1 lastCut = i + 1
} }
Binary file not shown.
Binary file not shown.