From 5a23f5a1ce232b532b45242b230e93d6dc5e0368 Mon Sep 17 00:00:00 2001 From: chexit Date: Fri, 13 Nov 2026 11:20:37 +0300 Subject: [PATCH] Restore Mini 2 touch controls and expressive replies --- README.md | 4 + music/main.go | 40 +++++ server/providers.mjs | 19 ++- station/conversation.go | 11 +- station/keyword.go | 23 ++- station/main.go | 6 +- station/panel.go | 291 ++++++++++++++++++++++++++++++++++ station/panel_test.go | 63 ++++++++ station/volume.go | 61 ------- test/providers-tools.test.mjs | 17 ++ 10 files changed, 458 insertions(+), 77 deletions(-) create mode 100644 station/panel.go create mode 100644 station/panel_test.go diff --git a/README.md b/README.md index 81f0972..3ebfee5 100644 --- a/README.md +++ b/README.md @@ -59,3 +59,7 @@ Caddy проксирует голосовое соединение и `/healthz` В «Пульте Алисы» можно включить сигнал активации и настроить окно продолжения разговора. По умолчанию сигнал отключён, а окно открывается на три секунды после голосового ответа. «Стоп» и «хватит» распознаются на колонке и прекращают текущий ответ. Spotify продолжает воспроизведение с обычным уровнем. Штатный локальный детектор также принимает «громче», «тише», «пауза», «продолжи», «дальше» и «назад». Голосовой ассистент может показать состояние Spotify Connect, назвать текущий трек, перемотать, включить повтор или перемешивание, посмотреть и дополнить очередь. Для изменения плейлистов нужно повторно авторизовать Spotify в Mini App. Пульт показывает состояние колонки и последний известный трек. Источник состояния воспроизведения — Spotify Connect. + +Тап по центру переключает воспроизведение Spotify или останавливает активный ответ. Удержание центра три секунды открывает прослушивание. Тап «−» или «+» меняет громкость на 10 процентных пунктов, удержание повторяет шаг. Свайп через три зоны от «−» к «+» включает следующий трек. Свайп обратно возвращает к началу текущего трека после пяти секунд воспроизведения и включает предыдущий в первые пять секунд. + +Eleven v4 Turbo и Eleven v3 Conversational могут получать один тег интонации в начале ответа. Модель выбирает подачу при генерации текста ответа. Повседневные фразы и короткие команды остаются нейтральными. В истории сохраняется чистый текст. diff --git a/music/main.go b/music/main.go index f2efac0..5525d6c 100644 --- a/music/main.go +++ b/music/main.go @@ -58,6 +58,7 @@ func execute(req request) (map[string]interface{}, error) { "pause": "pause", "resume": "play", "next": "skip_next", "previous": "skip_prev", "volume": "set_volume", "seek": "seek", "shuffle": "set_shuffle", "repeat": "set_repeat_context", "queue": "get_queue", "enqueue": "add_to_queue", "status": "get_state", + "toggle": "get_state", "swipe_previous": "get_state", } action, ok := actions[req.Arguments.Action] if !ok { @@ -123,6 +124,42 @@ func execute(req request) (map[string]interface{}, error) { } return nil, errors.New("spotify_not_paired") } + panelAction := "" + if req.Arguments.Action == "toggle" || req.Arguments.Action == "swipe_previous" { + if err := conn.WriteJSON(command); err != nil { + return nil, errors.New("spotify_disconnected") + } + for { + var event struct { + Type, Status string + Position struct { + Milliseconds int64 `json:"position_ms"` + } + } + if err := conn.ReadJSON(&event); err != nil || event.Type == "error" { + return nil, errors.New("spotify_state_unavailable") + } + if event.Type != "playback_state" { + continue + } + if req.Arguments.Action == "toggle" { + panelAction = "resume" + command["command"] = "play" + if event.Status == "playing" || event.Status == "buffering" { + panelAction = "pause" + command["command"] = "pause" + } + } else { + panelAction = "previous" + command["command"] = "skip_prev" + if event.Position.Milliseconds >= 5000 { + command["command"] = "seek" + command["position_ms"] = 0 + } + } + break + } + } // Soloist exposes context and track repeat as separate flags. if req.Arguments.Action == "repeat" { reset := "set_repeat_track" @@ -164,6 +201,9 @@ func execute(req request) (map[string]interface{}, error) { case "command_result": if event["command"] == command["command"] { accepted = true + if panelAction != "" { + return map[string]interface{}{"accepted": true, "action": panelAction}, nil + } if err := conn.WriteJSON(map[string]string{"type": "command", "command": "get_state"}); err != nil { return nil, errors.New("spotify_disconnected") } diff --git a/server/providers.mjs b/server/providers.mjs index 856497f..52c0ad9 100644 --- a/server/providers.mjs +++ b/server/providers.mjs @@ -69,6 +69,17 @@ export async function openTts(settings, signal, onAudio) { } const sentenceSegments = new Intl.Segmenter('ru', { granularity: 'sentence' }); +const deliveryTags = ['excited', 'curious', 'warmly', 'sympathetic', 'mischievously']; +const deliveryPrompt = `Обычные вопросы, команды и подтверждения озвучивай нейтрально. Если контекст явно эмоциональный, можешь начать окончательный ответ одним тегом подачи: ${deliveryTags.map(tag => `[${tag}]`).join(', ')}. Подбирай его по смыслу и настроению реплики. Используй сдержанную интонацию, не ставь теги внутри ответа, не добавляй звуковые эффекты и не комментируй выбор подачи.`; + +function spokenReply(content, detailed, expressive) { + const lead = /^\s*\[([a-z]+)\](?=\s|$)/iu.exec(content); + const tag = lead?.[1].toLowerCase(); + const clean = content.replace(/\[[a-z][a-z ,'-]{0,63}\]|\[[a-z][a-z ,'-]{0,63}$/giu, '').replace(/\s+/gu, ' ').trim(); + const answer = detailed ? clean : shortReply(clean); + const audio = answer && expressive && deliveryTags.includes(tag) ? `[${tag}] ${answer}` : answer; + return { answer, audio }; +} function shortReply(text) { const compact = Array.from(sentenceSegments.segment(text.trim()), part => part.segment).slice(0, 2).join('').trim(); @@ -87,9 +98,10 @@ export async function streamAnswer(settings, history, userText, signal, onText, return answer; } if (!env.OPENROUTER_API_KEY) throw new Error('llm_config'); + const expressive = settings.ttsModel === 'eleven_v4_turbo' || settings.ttsModel === 'eleven_v3_conversational'; const moscowDate = new Intl.DateTimeFormat('ru-RU', { timeZone: 'Europe/Moscow', dateStyle: 'long' }).format(new Date()); const systemPrompt = `${settings.systemPrompt}\nСегодня ${moscowDate} (Москва). Используй веб-поиск для актуальных сведений и когда требуется проверить сомнительные факты. Твоя языковая модель: ${settings.llmModel}. Обычно отвечай одной короткой фразой, максимум двумя предложениями. Подробности давай по просьбе. На простой вопрос отвечай сразу, без служебных оговорок, и не возвращайся к предыдущим темам без просьбы. При неясности задай один короткий уточняющий вопрос. Вызывай инструменты без вступительных фраз. Сообщай результат команды один раз. Упоминай громкость, только если её спросили или попросили изменить. Подтверждай трек, только если инструмент вернул актуальный трек. Отвечай для озвучивания без Markdown и ссылок, если их не попросили.`; - const messages = [{ role: 'system', content: systemPrompt }, ...history.slice(-10).flatMap(t => [{ role: 'user', content: t.user }, { role: 'assistant', content: t.assistant }]), { role: 'user', content: userText }]; + const messages = [{ role: 'system', content: expressive ? `${systemPrompt}\n${deliveryPrompt}` : systemPrompt }, ...history.slice(-10).flatMap(t => [{ role: 'user', content: t.user }, { role: 'assistant', content: t.assistant }]), { role: 'user', content: userText }]; const detailed = /подробн|развернут|пошагов|детальн/iu.test(userText); let totalCalls = 0; for (let round = 0; round < 4; round++) { @@ -134,8 +146,9 @@ export async function streamAnswer(settings, history, userText, signal, onText, if (!done || !finishReason) throw new Error('llm_incomplete'); if (!calls.size) { if (!['stop', 'length'].includes(finishReason) || !content.trim()) throw new Error('llm_incomplete'); - const answer = detailed ? content.trim() : shortReply(content); - onText(answer); + const { answer, audio } = spokenReply(content, detailed, expressive); + if (!answer) throw new Error('llm_incomplete'); + onText(audio); return answer; } if (finishReason !== 'tool_calls') throw new Error('llm_incomplete'); diff --git a/station/conversation.go b/station/conversation.go index 58f36b1..6172dd5 100644 --- a/station/conversation.go +++ b/station/conversation.go @@ -12,9 +12,10 @@ import ( ) type conversation struct { - mu sync.Mutex - cancel context.CancelFunc - commands chan byte + mu sync.Mutex + cancel context.CancelFunc + commands chan byte + panelWake chan struct{} } func (c *conversation) begin() (context.Context, func()) { @@ -30,12 +31,14 @@ func (c *conversation) begin() (context.Context, func()) { } } -func (c *conversation) stop() { +func (c *conversation) stop() bool { c.mu.Lock() defer c.mu.Unlock() if c.cancel != nil { c.cancel() + return true } + return false } func (c *conversation) command(cmd byte) { diff --git a/station/keyword.go b/station/keyword.go index 5fc02ae..8923ede 100644 --- a/station/keyword.go +++ b/station/keyword.go @@ -83,7 +83,7 @@ func (d *keywordDetector) close() { d.cmd.Wait() } -func runKeywordWake(live *liveReader, detector *keywordDetector, activate func([]int16) error, once bool) error { +func runKeywordWake(live *liveReader, detector *keywordDetector, panelWake <-chan struct{}, activate func([]int16) error, once bool) error { if detector == nil { log.Print("Listening locally with the Mini 2 firmware spotter") } else { @@ -111,6 +111,12 @@ func runKeywordWake(live *liveReader, detector *keywordDetector, activate func([ continue } muted = false + button := false + select { + case <-panelWake: + button = true + default: + } copy(preroll[next:], frame.pcm) next = (next + hop) % len(preroll) available = min(available+hop, len(preroll)) @@ -121,15 +127,20 @@ func runKeywordWake(live *liveReader, detector *keywordDetector, activate func([ return err } } - if !detected { + if !detected && !button { continue } - log.Print("wake word detected") audio := make([]int16, 0, available) - if available == len(preroll) { - audio = append(audio, preroll[next:]...) + if button { + log.Print("touch panel activated assistant") + live.discardPending() + } else { + log.Print("wake word detected") + if available == len(preroll) { + audio = append(audio, preroll[next:]...) + } + audio = append(audio, preroll[:next]...) } - audio = append(audio, preroll[:next]...) if err := activate(audio); err != nil { log.Printf("session: %v", err) if !errors.Is(err, errMuted) && !errors.Is(err, errNoSpeech) && !errors.Is(err, context.Canceled) { diff --git a/station/main.go b/station/main.go index b8212d1..198634d 100644 --- a/station/main.go +++ b/station/main.go @@ -538,7 +538,7 @@ func main() { log.Fatal(e) } } - dialog := &conversation{commands: make(chan byte, 4)} + dialog := &conversation{commands: make(chan byte, 4), panelWake: make(chan struct{}, 1)} go dialog.localCommands() if *mode == "wake" { go reportPlayback(*url, token) @@ -584,7 +584,7 @@ func main() { } } - go listenVolumeButtons(*playback, dialog.stop) + go listenPanel(*playback, dialog) if *keywordModel != "" || *spotterModel != "" { var detector *keywordDetector if *keywordModel != "" { @@ -595,7 +595,7 @@ func main() { } defer detector.close() } - err := runKeywordWake(live, detector, func(preroll []int16) error { + err := runKeywordWake(live, detector, dialog.panelWake, func(preroll []int16) error { return dialog.dialogue(*url, token, live, preroll, *th, *max, *silence, *playback, *oneTurn) }, *oneTurn) if err != nil { diff --git a/station/panel.go b/station/panel.go new file mode 100644 index 0000000..840c63e --- /dev/null +++ b/station/panel.go @@ -0,0 +1,291 @@ +package main + +import ( + "context" + "encoding/binary" + "encoding/json" + "io" + "log" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + panelMinus = 114 + panelPlus = 115 + panelCenter = 582 + + panelMinTap = 18 * time.Millisecond + panelGap = 155 * time.Millisecond + panelMaxSwipe = 520 * time.Millisecond + panelHold = 500 * time.Millisecond + panelRepeat = 250 * time.Millisecond + panelWakeHold = 3 * time.Second + panelCooldown = 170 * time.Millisecond + panelMaxHold = 6 * time.Second +) + +type panelAction string + +const ( + panelQuieter panelAction = "quieter" + panelLouder panelAction = "louder" + panelToggle panelAction = "toggle" + panelNext panelAction = "next" + panelPrevious panelAction = "swipe_previous" + panelWake panelAction = "wake" +) + +type panelGesture struct { + path [3]uint16 + count int + down uint8 + start time.Time + lastRelease time.Time + nextHold time.Time + cooldown time.Time + held bool + invalid bool + ignoreToSync bool +} + +func panelZone(code uint16) uint8 { + switch code { + case panelMinus: + return 1 + case panelCenter: + return 2 + case panelPlus: + return 4 + } + return 0 +} + +func (g *panelGesture) finish(now time.Time) panelAction { + var action panelAction + if !g.held && !g.invalid { + switch { + case g.count == 1 && g.lastRelease.Sub(g.start) >= panelMinTap: + switch g.path[0] { + case panelMinus: + action = panelQuieter + case panelPlus: + action = panelLouder + case panelCenter: + action = panelToggle + } + case g.count == 3 && g.lastRelease.Sub(g.start) <= panelMaxSwipe: + if g.path == [3]uint16{panelMinus, panelCenter, panelPlus} { + action = panelNext + } else if g.path == [3]uint16{panelPlus, panelCenter, panelMinus} { + action = panelPrevious + } + } + } + cooldown := g.cooldown + if action == panelNext || action == panelPrevious { + cooldown = now.Add(panelCooldown) + } + *g = panelGesture{cooldown: cooldown} + return action +} + +func (g *panelGesture) tick(now time.Time) panelAction { + if g.count == 0 { + return "" + } + if g.down == 0 && !now.Before(g.lastRelease.Add(panelGap)) { + return g.finish(now) + } + if g.down == 0 || g.invalid || g.count != 1 || now.Before(g.nextHold) { + return "" + } + g.held = true + if now.Sub(g.start) >= panelMaxHold { + g.invalid = true + return "" + } + g.nextHold = now.Add(panelRepeat) + switch g.path[0] { + case panelMinus: + return panelQuieter + case panelPlus: + return panelLouder + case panelCenter: + g.invalid = true + return panelWake + } + return "" +} + +func (g *panelGesture) key(now time.Time, code uint16, value uint32) { + zone := panelZone(code) + if zone == 0 || g.ignoreToSync || value > 1 { + return + } + if value == 0 { + if g.down&zone != 0 { + g.down &^= zone + g.lastRelease = now + } + return + } + if g.down&zone != 0 { + return + } + g.down |= zone + if g.count == 0 { + g.start = now + g.nextHold = now.Add(panelHold) + if code == panelCenter { + g.nextHold = now.Add(panelWakeHold) + } + g.invalid = now.Before(g.cooldown) + } + if g.held || g.count == len(g.path) { + g.invalid = true + return + } + g.path[g.count] = code + g.count++ +} + +type panelEvent struct { + kind, code uint16 + value uint32 +} + +func readPanel(device io.Reader, events chan<- panelEvent) error { + defer close(events) + // The ARM64 process sees a 16-byte timeval before the event fields. + var raw [24]byte + for { + if _, err := io.ReadFull(device, raw[:]); err != nil { + return err + } + events <- panelEvent{ + kind: binary.LittleEndian.Uint16(raw[16:18]), + code: binary.LittleEndian.Uint16(raw[18:20]), + value: binary.LittleEndian.Uint32(raw[20:24]), + } + } +} + +func listenPanel(playback string, dialog *conversation) { + names, _ := filepath.Glob("/sys/class/input/event*/device/name") + for _, path := range names { + name, err := os.ReadFile(path) + if err != nil || strings.TrimSpace(string(name)) != "sar0_pad0" { + continue + } + event := filepath.Base(filepath.Dir(filepath.Dir(path))) + device, err := os.Open(filepath.Join("/dev/input", event)) + if err != nil { + log.Printf("open touch panel: %v", err) + return + } + defer device.Close() + log.Printf("touch panel ready on %s", event) + events := make(chan panelEvent, 24) + go func() { + if err := readPanel(device, events); err != nil { + log.Printf("read touch panel: %v", err) + } + }() + runPanel(events, playback, dialog) + return + } + log.Print("touch panel input device unavailable") +} + +func runPanel(events <-chan panelEvent, playback string, dialog *conversation) { + volume := make(chan panelAction, 1) + music := make(chan panelAction, 1) + defer close(volume) + defer close(music) + go func() { + for action := range volume { + delta := 10 + if action == panelQuieter { + delta = -10 + } + level, err := changeVolume(delta) + if err != nil { + log.Printf("panel volume: %v", err) + continue + } + lights.showVolume(level) + go playVolumeTone(playback, level) + } + }() + go func() { + for action := range music { + args, _ := json.Marshal(map[string]string{"action": string(action)}) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + result := musicCallContext(ctx, "spotify_control", args) + cancel() + var reply struct { + Error, Action string + Accepted bool + } + if err := json.Unmarshal(result, &reply); err != nil || reply.Error != "" { + log.Printf("panel Spotify action %s failed", action) + lights.confirm("error") + } else if reply.Accepted { + feedback := reply.Action + if action == panelNext { + feedback = "next" + } + lights.confirm(feedback) + } + } + }() + emit := func(action panelAction) { + switch action { + case panelWake: + if unmuted() && !dialog.stop() { + select { + case dialog.panelWake <- struct{}{}: + default: + } + } + case panelQuieter, panelLouder: + select { + case volume <- action: + default: + } + case panelToggle, panelNext, panelPrevious: + if action == panelToggle && dialog.stop() { + return + } + select { + case music <- action: + default: + } + } + } + var gesture panelGesture + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + select { + case event, ok := <-events: + if !ok { + return + } + now := time.Now() + if event.kind == 0 && event.code == 3 { // SYN_DROPPED + gesture = panelGesture{ignoreToSync: true} + } else if event.kind == 0 && event.code == 0 { + gesture.ignoreToSync = false + } else if event.kind == 1 { + emit(gesture.tick(now)) + gesture.key(now, event.code, event.value) + } + case now := <-ticker.C: + emit(gesture.tick(now)) + } + } +} diff --git a/station/panel_test.go b/station/panel_test.go new file mode 100644 index 0000000..baab02d --- /dev/null +++ b/station/panel_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "testing" + "time" +) + +func TestPanelSwipeAndPartialSwipeAreExclusive(t *testing.T) { + for _, tc := range []struct { + zones []uint16 + want panelAction + }{ + {[]uint16{panelMinus, panelCenter, panelPlus}, panelNext}, + {[]uint16{panelPlus, panelCenter, panelMinus}, panelPrevious}, + {[]uint16{panelMinus, panelCenter}, ""}, + {[]uint16{panelMinus, panelCenter, panelMinus}, ""}, + {[]uint16{panelPlus}, panelLouder}, + {[]uint16{panelCenter}, panelToggle}, + } { + var g panelGesture + now := time.Unix(100, 0) + for _, code := range tc.zones { + g.key(now, code, 1) + now = now.Add(45 * time.Millisecond) + g.key(now, code, 0) + now = now.Add(35 * time.Millisecond) + if got := g.tick(now); got != "" { + t.Fatalf("%v: action before end of gesture: %q", tc.zones, got) + } + } + now = now.Add(panelGap) + if got := g.tick(now); got != tc.want { + t.Fatalf("%v: got %q, want %q", tc.zones, got, tc.want) + } + if got := g.tick(now.Add(time.Second)); got != "" { + t.Fatalf("%v: unexpected duplicate action %q", tc.zones, got) + } + } +} + +func TestPanelHoldRepeatsWithoutReleaseTap(t *testing.T) { + var g panelGesture + start := time.Unix(100, 0) + g.key(start, panelMinus, 1) + g.key(start.Add(100*time.Millisecond), panelMinus, 2) + if got := g.tick(start.Add(499 * time.Millisecond)); got != "" { + t.Fatalf("early hold: %q", got) + } + if got := g.tick(start.Add(panelHold)); got != panelQuieter { + t.Fatalf("first hold step: %q", got) + } + if got := g.tick(start.Add(panelHold + panelRepeat)); got != panelQuieter { + t.Fatalf("repeat hold step: %q", got) + } + g.key(start.Add(time.Second), panelMinus, 0) + if got := g.tick(start.Add(2 * time.Second)); got != "" { + t.Fatalf("hold release emitted %q", got) + } + g.key(start.Add(3*time.Second), panelCenter, 1) + if got := g.tick(start.Add(3*time.Second + panelWakeHold)); got != panelWake { + t.Fatalf("center hold emitted %q", got) + } +} diff --git a/station/volume.go b/station/volume.go index a1a769a..18830f4 100644 --- a/station/volume.go +++ b/station/volume.go @@ -3,17 +3,13 @@ package main import ( "bytes" "context" - "encoding/binary" "encoding/json" "errors" - "io" "log" "math" "os" "os/exec" - "path/filepath" "strconv" - "strings" "sync" "time" ) @@ -24,63 +20,6 @@ const pulseControl = "/data/opt/alice-backend/spotify/pactl" var volumeMu sync.Mutex var toneMu sync.Mutex -func listenVolumeButtons(playback string, stop func()) { - names, _ := filepath.Glob("/sys/class/input/event*/device/name") - for _, path := range names { - name, err := os.ReadFile(path) - if err != nil || strings.TrimSpace(string(name)) != "sar0_pad0" { - continue - } - event := filepath.Base(filepath.Dir(filepath.Dir(path))) - device, err := os.Open(filepath.Join("/dev/input", event)) - if err != nil { - log.Printf("open volume buttons: %v", err) - return - } - defer device.Close() - // Linux ARM64 input_event starts with a 16-byte timeval. - var eventData [24]byte - for { - if _, err := io.ReadFull(device, eventData[:]); err != nil { - log.Printf("read volume buttons: %v", err) - return - } - if binary.LittleEndian.Uint16(eventData[16:18]) == 1 && - binary.LittleEndian.Uint16(eventData[18:20]) == 582 && - binary.LittleEndian.Uint32(eventData[20:24]) == 1 { - stop() - continue - } - if delta := volumeStep(eventData); delta != 0 { - level, err := changeVolume(delta) - if err != nil { - log.Printf("volume button: %v", err) - continue - } - lights.showVolume(level) - go playVolumeTone(playback, level) - } - } - } - log.Print("volume buttons input device unavailable") -} - -func volumeStep(event [24]byte) int { - eventType := binary.LittleEndian.Uint16(event[16:18]) - code := binary.LittleEndian.Uint16(event[18:20]) - value := binary.LittleEndian.Uint32(event[20:24]) - if eventType != 1 || (value != 1 && value != 2) { - return 0 - } - switch code { - case 114: // KEY_VOLUMEDOWN - return -10 - case 115: // KEY_VOLUMEUP - return 10 - } - return 0 -} - func changeVolume(delta int) (int, error) { volumeMu.Lock() defer volumeMu.Unlock() diff --git a/test/providers-tools.test.mjs b/test/providers-tools.test.mjs index 488652e..1e9fe15 100644 --- a/test/providers-tools.test.mjs +++ b/test/providers-tools.test.mjs @@ -20,6 +20,23 @@ test.after(() => server.close()); const event = (delta, finish_reason = null) => ({ choices: [{ delta, finish_reason }] }); const settings = { systemPrompt: 'test', llmModel: 'test' }; +test('chooses delivery in the answer request and keeps audio tags out of history', async () => { + requests.length = 0; + responder = () => [event({ content: '[exci' }), event({ content: 'ted] Ура! [shouts] Поздравляю!' }, 'stop')]; + const spoken = []; + const expressive = { ...settings, ttsModel: 'eleven_v4_turbo' }; + assert.equal(await streamAnswer(expressive, [], 'Я выиграл!', AbortSignal.timeout(2000), part => spoken.push(part)), 'Ура! Поздравляю!'); + assert.deepEqual(spoken, ['[excited] Ура! Поздравляю!']); + assert.equal(requests.length, 1); + assert.match(requests[0].messages[0].content, /\[curious\]/); + + const neutral = { ...settings, ttsModel: 'eleven_flash_v2_5' }; + spoken.length = 0; + assert.equal(await streamAnswer(neutral, [], 'Я выиграл!', AbortSignal.timeout(2000), part => spoken.push(part)), 'Ура! Поздравляю!'); + assert.deepEqual(spoken, ['Ура! Поздравляю!']); + assert.doesNotMatch(requests[1].messages[0].content, /\[curious\]/); +}); + test('assembles fragmented calls before executing and streams text', async () => { requests.length = 0; responder = n => n === 1 ? [event({ content: 'Сейчас включу.', tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'spotify_play', arguments: '{"query":"' } }] }), event({ tool_calls: [{ index: 0, function: { arguments: 'Queen"}' } }] }, 'tool_calls')] : [event({ content: 'Готово' }, 'stop')];