diff --git a/.env.example b/.env.example index a183203..16deed9 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,5 @@ OPENROUTER_API_KEY= TELEGRAM_BOT_TOKEN= TELEGRAM_OWNER_ID= ADMIN_HOST=miniapp.pepega.systems +SPOTIFY_CLIENT_ID= +SPOTIFY_REDIRECT_URI=https://miniapp.pepega.systems/api/spotify/callback diff --git a/README.md b/README.md index 8e0d4f6..9622015 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Алиса для Станции Мини 2 -Собственный голосовой клиент для рутированной Mini 2, сервер на Node.js и пульт в Telegram. На колонке работает экспериментальный детектор, обученный локально на нескольких произнесениях «Алиса». После обнаружения он подаёт сигнал. Команду надо произнести после сигнала. +Собственный голосовой клиент для рутированной Mini 2, сервер на Node.js и пульт в Telegram. На колонке работает экспериментальный детектор, обученный локально на нескольких произнесениях «Алиса». Когда клиент готов принимать команду, верхнее кольцо загорается сине-фиолетовым. Произнеси команду после включения кольца. ```text Mini 2 → WSS с PCM16 / 16 кГц → Scribe v2 Realtime @@ -29,7 +29,7 @@ node --env-file=.env server/index.mjs docker compose -f deploy/compose.yaml up -d --build ``` -Caddy проксирует голосовое соединение и `/healthz` на `alice.pepega.systems`, а пульт и API на `miniapp.pepega.systems`. История и редактирование настроек требуют Telegram `initData` с проверенной подписью и ID владельца из `TELEGRAM_OWNER_ID`. В пульте можно выбрать модель OpenRouter, голос и модель синтеза, изменить системный промпт и очистить историю. В текущем API распознавания в реальном времени доступна одна модель. +Caddy проксирует голосовое соединение и `/healthz` на `alice.pepega.systems`, а пульт и API на `miniapp.pepega.systems`. История и редактирование настроек требуют Telegram `initData` с проверенной подписью и ID владельца из `TELEGRAM_OWNER_ID`. В пульте можно выбрать модель OpenRouter, голос и модель синтеза, изменить системный промпт, подключить Spotify и очистить историю. В текущем API распознавания в реальном времени доступна одна модель. ## Протокол колонки @@ -38,8 +38,15 @@ Caddy проксирует голосовое соединение и `/healthz` 1. Сервер посылает `{"type":"ready"}`. 2. Клиент посылает `{"type":"start"}`, затем бинарные блоки mono PCM16LE 16 кГц и `{"type":"end"}`. 3. Сервер присылает JSON `partial` и `transcript` с полем `text`, затем бинарные блоки mono PCM16LE 24 кГц. -4. Сервер присылает `{"type":"done"}` или `{"type":"error","code":"…"}` и закрывает соединение. +4. Для команды Spotify сервер присылает `device_tool` с полями `id`, `name` и `arguments`. Клиент возвращает `device_tool_result` с тем же `id` и полем `result`. +5. Сервер присылает `{"type":"done"}` или `{"type":"error","code":"…"}` и закрывает соединение. -## Музыка +## Spotify и поиск в интернете -Spotify Connect и управление музыкой пока не подключены. Для ручного Connect Spotify предлагает [Soloist](https://developer.spotify.com/documentation/soloist), которому нужны собственный ключ Spotify и PipeWire или PulseAudio на Linux. На Mini 2 штатный тракт ALSA. [Правила Spotify для разработчиков](https://developer.spotify.com/compliance-tips) запрещают использовать их инструменты для собственного голосового управления. +Музыку на колонке воспроизводит официальный Spotify Soloist. На Mini 2 он запускается с отдельным ARM64 runtime и PulseAudio. Музыка и ответы идут через разные входы микшера. Кольцо включается после приглушения музыки, а после ответа громкость музыкального входа восстанавливается. + +Для поиска по названию создай Spotify Web API приложение с Redirect URI `https://miniapp.pepega.systems/api/spotify/callback`, задай его Client ID в `SPOTIFY_CLIENT_ID` на сервере и подключи аккаунт в пульте. Soloist отдельно требует свой ключ и первый выбор устройства «Pepega Mini 2» в приложении Spotify. Сессия Connect сохраняется на колонке. + +Клод вызывает `spotify_play` для трека, исполнителя, альбома или плейлиста. Поддерживаются пауза, продолжение, следующий и предыдущий трек и установка громкости. Для актуальных сведений и проверки неизвестных фактов Клод может вызвать [серверный веб-поиск OpenRouter](https://openrouter.ai/docs/guides/features/server-tools/web-search). + +[Spotify Soloist](https://developer.spotify.com/documentation/soloist) · [Рецепт librespot для сравнения](https://hackalice.me/recipes/spotify/) diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 9a2a752..50df6ac 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -10,7 +10,7 @@ alice.pepega.systems { } miniapp.pepega.systems { - @miniapp path / /style.css /app.js /api/admin /api/admin/* + @miniapp path / /style.css /app.js /api/admin /api/admin/* /api/spotify/start /api/spotify/callback handle @miniapp { reverse_proxy alice-backend:3000 } diff --git a/music/cache.go b/music/cache.go new file mode 100644 index 0000000..fdd7b28 --- /dev/null +++ b/music/cache.go @@ -0,0 +1,113 @@ +package main + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "time" +) + +const ( + cacheBytesLimit = int64(16 << 20) + cacheFilesLimit = 1024 + cacheScanLimit = 4096 + cacheGrace = 30 * time.Second +) + +var errScanLimit = errors.New("cache scan limit reached") + +type cacheFile struct { + path string + size int64 + mod time.Time + info fs.FileInfo +} + +// cleanCache operates only in the dedicated Soloist cache directory. Recent files +// are left alone to avoid interfering with downloads still being written. +func cleanCache(root string, now time.Time) error { + info, err := os.Lstat(root) + if err != nil { + return err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("cache root must be a directory, not a symlink") + } + var files []cacheFile + var total int64 + scanned := 0 + err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + scanned++ + if scanned > cacheScanLimit { + return errScanLimit + } + if !d.Type().IsRegular() { + return nil + } + i, err := d.Info() + if err != nil { + return err + } + if !i.Mode().IsRegular() { + return nil + } + files = append(files, cacheFile{path, i.Size(), i.ModTime(), i}) + total += i.Size() + return nil + }) + if err != nil && !errors.Is(err, errScanLimit) { + return err + } + sort.Slice(files, func(i, j int) bool { return files[i].mod.Before(files[j].mod) }) + count := len(files) + for _, f := range files { + if total <= cacheBytesLimit && count <= cacheFilesLimit && !errors.Is(err, errScanLimit) { + break + } + if now.Sub(f.mod) < cacheGrace { + continue + } + // Do not follow symlinks or delete a file that changed during the scan. + current, e := os.Lstat(f.path) + if e != nil { + if errors.Is(e, os.ErrNotExist) { + continue + } + return e + } + if !current.Mode().IsRegular() || !os.SameFile(f.info, current) || current.Size() != f.size || !current.ModTime().Equal(f.mod) { + continue + } + if e = os.Remove(f.path); e != nil && !errors.Is(e, os.ErrNotExist) { + return e + } + total -= f.size + count-- + // When truncated, clear enough entries for the next traversal to advance. + if errors.Is(err, errScanLimit) && count <= cacheFilesLimit { + break + } + } + if errors.Is(err, errScanLimit) { + return fmt.Errorf("%w; will retry", err) + } + return nil +} + +func runCacheJanitor(root string) error { + if err := os.MkdirAll(root, 0700); err != nil { + return err + } + for { + if err := cleanCache(root, time.Now()); err != nil { + fmt.Fprintln(os.Stderr, "spotify cache:", err) + } + time.Sleep(3 * time.Second) + } +} diff --git a/music/cache_test.go b/music/cache_test.go new file mode 100644 index 0000000..b87c40d --- /dev/null +++ b/music/cache_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestCleanCacheOldestAndSymlink(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + now := time.Now() + create := func(path string, size int64, age time.Duration) { + t.Helper() + f, e := os.Create(path) + if e != nil { + t.Fatal(e) + } + if e = f.Truncate(size); e != nil { + t.Fatal(e) + } + f.Close() + m := now.Add(-age) + if e = os.Chtimes(path, m, m); e != nil { + t.Fatal(e) + } + } + old := filepath.Join(root, "old") + newer := filepath.Join(root, "newer") + active := filepath.Join(root, "active") + target := filepath.Join(outside, "keep") + create(old, 10<<20, time.Hour) + create(newer, 8<<20, time.Minute) + create(active, 1<<20, 0) + create(target, 1, time.Hour) + if e := os.Symlink(target, filepath.Join(root, "link")); e != nil { + t.Fatal(e) + } + if e := cleanCache(root, now); e != nil { + t.Fatal(e) + } + if _, e := os.Stat(old); !os.IsNotExist(e) { + t.Fatalf("old file still exists: %v", e) + } + for _, p := range []string{newer, active, target} { + if _, e := os.Stat(p); e != nil { + t.Fatal(e) + } + } +} + +func TestCleanCacheProtectsRecentAndRejectsSymlinkRoot(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "active") + f, e := os.Create(path) + if e != nil { + t.Fatal(e) + } + if e = f.Truncate(20 << 20); e != nil { + t.Fatal(e) + } + f.Close() + if e = cleanCache(root, time.Now()); e != nil { + t.Fatal(e) + } + if _, e = os.Stat(path); e != nil { + t.Fatal(e) + } + link := filepath.Join(t.TempDir(), "link") + if e = os.Symlink(root, link); e != nil { + t.Fatal(e) + } + if e = cleanCache(link, time.Now()); e == nil { + t.Fatal("symlink root accepted") + } +} diff --git a/music/go.mod b/music/go.mod new file mode 100644 index 0000000..32c1a89 --- /dev/null +++ b/music/go.mod @@ -0,0 +1,5 @@ +module alice-back/music + +go 1.21 + +require github.com/gorilla/websocket v1.5.3 diff --git a/music/go.sum b/music/go.sum new file mode 100644 index 0000000..25a9fc4 --- /dev/null +++ b/music/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/music/main.go b/music/main.go new file mode 100644 index 0000000..14bd395 --- /dev/null +++ b/music/main.go @@ -0,0 +1,144 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strings" + "time" + + "github.com/gorilla/websocket" +) + +type request struct { + Name string `json:"name"` + Arguments struct { + URI string `json:"uri"` + Action string `json:"action"` + Volume *int `json:"volume"` + Active bool `json:"active"` + } `json:"arguments"` +} + +var validURI = regexp.MustCompile(`^spotify:(track|artist|album|playlist|episode):[A-Za-z0-9]{22}$`) + +func execute(req request) (map[string]interface{}, error) { + if req.Name == "duck" { + volume := "100%" + if req.Arguments.Active { + volume = "18%" + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "/data/opt/alice-backend/spotify/pactl", "set-sink-volume", "music", volume) + if err := cmd.Run(); err != nil { + return nil, errors.New("music_audio_unavailable") + } + return map[string]interface{}{"ok": true}, nil + } + + command := map[string]interface{}{"type": "command"} + if req.Name == "spotify_play" { + if !validURI.MatchString(req.Arguments.URI) { + return nil, errors.New("invalid_spotify_uri") + } + command["command"] = "play" + command["uri"] = req.Arguments.URI + } else if req.Name == "spotify_control" { + actions := map[string]string{"pause": "pause", "resume": "play", "next": "skip_next", "previous": "skip_prev", "volume": "set_volume"} + action, ok := actions[req.Arguments.Action] + if !ok { + return nil, errors.New("invalid_music_action") + } + command["command"] = action + if action == "set_volume" { + if req.Arguments.Volume == nil || *req.Arguments.Volume < 0 || *req.Arguments.Volume > 100 { + return nil, errors.New("invalid_music_volume") + } + command["volume"] = *req.Arguments.Volume + } + } else { + return nil, errors.New("unknown_music_tool") + } + dialer := websocket.Dialer{HandshakeTimeout: 2 * time.Second} + conn, _, err := dialer.Dial("ws://127.0.0.1:9090", nil) + if err != nil { + return nil, errors.New("spotify_unavailable") + } + defer conn.Close() + conn.SetReadLimit(256 * 1024) + deadline := time.Now().Add(10 * time.Second) + _ = conn.SetReadDeadline(deadline) + _ = conn.SetWriteDeadline(deadline) + var auth struct { + Type string `json:"type"` + LoggedIn bool `json:"logged_in"` + } + if err := conn.ReadJSON(&auth); err != nil || auth.Type != "auth_state" || !auth.LoggedIn { + return nil, errors.New("spotify_not_paired") + } + if err := conn.WriteJSON(command); err != nil { + return nil, errors.New("spotify_disconnected") + } + accepted := false + for { + var event map[string]interface{} + if err := conn.ReadJSON(&event); err != nil { + if accepted { + return map[string]interface{}{"accepted": true, "status": "pending"}, nil + } + return nil, errors.New("spotify_response_timeout") + } + switch event["type"] { + case "error": + return nil, errors.New("spotify_command_failed") + case "command_result": + if event["command"] == command["command"] { + accepted = true + if err := conn.WriteJSON(map[string]string{"type": "command", "command": "get_state"}); err != nil { + return nil, errors.New("spotify_disconnected") + } + } + case "playback_state": + if accepted { + return map[string]interface{}{"accepted": true, "status": event["status"], "item": event["item"], "volume": event["volume"]}, nil + } + } + } +} + +func main() { + cacheDir := flag.String("cache-janitor", "", "maintain the dedicated Soloist cache directory") + flag.Parse() + if *cacheDir != "" { + if err := runCacheJanitor(*cacheDir); err != nil { + fmt.Fprintln(os.Stderr, "spotify cache:", err) + os.Exit(1) + } + return + } + var req request + decoder := json.NewDecoder(io.LimitReader(os.Stdin, 8193)) + decoder.DisallowUnknownFields() + var result map[string]interface{} + if err := decoder.Decode(&req); err != nil { + result = map[string]interface{}{"error": "invalid_music_request"} + } else { + var err error + result, err = execute(req) + if err != nil { + result = map[string]interface{}{"error": strings.TrimSpace(err.Error())} + } + } + data, err := json.Marshal(result) + if err != nil || len(data) > 12000 { + data = []byte(`{"error":"music_result_too_large"}`) + } + fmt.Println(string(data)) +} diff --git a/scripts/build-spotify-runtime.sh b/scripts/build-spotify-runtime.sh new file mode 100755 index 0000000..731982f --- /dev/null +++ b/scripts/build-spotify-runtime.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Build on an x86-64 Linux Docker host; writes the archive to the current directory. +set -euo pipefail +out="${1:-$PWD/alice-spotify-runtime.tar.gz}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +cat > "$work/build.sh" <<'INNER' +#!/bin/bash +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive +dpkg --add-architecture arm64 +apt-get update -qq +apt-get install -y -qq --no-install-recommends curl ca-certificates binutils python3 > /dev/null +mkdir -p /work/debs/partial /work/root /work/out/lib /work/out/bin /work/out/etc +# An empty dpkg status prevents installed amd64 packages from satisfying arm64 dependencies. +apt-get -y --download-only --no-install-recommends -o APT::Architecture=arm64 -o Dir::State::status=/dev/null -o Dir::Cache::archives=/work/debs install pulseaudio:arm64 pulseaudio-utils:arm64 > /work/apt.log +for f in /work/debs/*.deb; do dpkg-deb -x "$f" /work/root; done +curl -fL --retry 3 https://soloist-builds.spotifycdn.com/soloist_release_arm64.tar.gz -o /work/soloist.tar.gz +mkdir /work/soloist +tar -xzf /work/soloist.tar.gz -C /work/soloist +find /work/soloist -type f -maxdepth 4 -print +cp /work/root/usr/bin/pulseaudio /work/root/usr/bin/pacat /work/root/usr/bin/pactl /work/out/bin/ +cp -a /work/soloist/. /work/out/soloist/ +# Follow DT_NEEDED edges, including module dependencies and PulseAudio's private libs. +python3 - <<'PY' +import os, subprocess, shutil, glob +root='/work/root'; out='/work/out'; paths={} +for base, dirs, files in os.walk(root): + for f in files: + p=os.path.join(base,f) + if '.so' in f and os.path.isfile(p): paths.setdefault(f,p) +seeds=[out+'/bin/pulseaudio',out+'/bin/pacat',out+'/bin/pactl'] +for base,dirs,files in os.walk(out+'/soloist'): + for f in files: + p=os.path.join(base,f) + if open(p,'rb').read(4)==b'\x7fELF': seeds.append(p) +mods=glob.glob(root+'/usr/lib/aarch64-linux-gnu/pulseaudio/modules/module-{alsa-sink,native-protocol-unix}.so') +# glob does not expand braces. +mods=[glob.glob(root+'/usr/lib/**/module-'+n+'.so',recursive=True)[0] for n in ('alsa-sink','native-protocol-unix','remap-sink')] +for p in mods: shutil.copy2(p,out+'/lib/'); seeds.append(out+'/lib/'+os.path.basename(p)) +seen=set(); missing=set() +while seeds: + p=seeds.pop() + try: s=subprocess.check_output(['readelf','-d',p],stderr=subprocess.DEVNULL,text=True) + except subprocess.CalledProcessError: continue + for line in s.splitlines(): + if '(NEEDED)' not in line: continue + name=line.split('[')[1].split(']')[0] + if name in seen: continue + seen.add(name) + if name not in paths: missing.add(name); continue + src=paths[name]; dest=out+'/lib/'+name + shutil.copy2(src,dest,follow_symlinks=True); seeds.append(dest) +if missing: raise SystemExit('Missing ELF dependencies: '+', '.join(sorted(missing))) +loader=glob.glob(root+'/lib/ld-linux-aarch64.so.1')+glob.glob(root+'/usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1') +shutil.copy2(loader[0],out+'/lib/ld-linux-aarch64.so.1',follow_symlinks=True) +PY +# ALSA configuration is data loaded at runtime, not an ELF dependency. +cp -a /work/root/usr/share/alsa /work/out/alsa +cat > /work/out/etc/default.pa <<'PA' +load-module module-native-protocol-unix auth-anonymous=1 +load-module module-alsa-sink sink_name=station device=hw:0,0 +load-module module-remap-sink sink_name=music master=station +set-default-sink music +PA +cat > /work/out/etc/daemon.conf <<'PA' +daemonize = no +exit-idle-time = -1 +flat-volumes = no +PA +cat > /work/out/run <<'RUN' +#!/bin/sh +set -eu +base=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +export ALSA_CONFIG_PATH="$base/alsa/alsa.conf" +export ALSA_CONFIG_DIR="$base/alsa" +export PULSE_RUNTIME_PATH="${PULSE_RUNTIME_PATH:-/data/local/tmp/alice-pulse}" +export PULSE_CONFIG_PATH="$base/etc" +mkdir -p "$PULSE_RUNTIME_PATH" +exec "$base/lib/ld-linux-aarch64.so.1" --library-path "$base/lib" "$@" +RUN +chmod +x /work/out/run +(cd /work/out && find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS) +for f in /work/debs/*.deb; do dpkg-deb -f "$f" Package Version Architecture; printf "\n"; done > /work/out/DEBIAN-PACKAGES.txt +(cd /work/out && sha256sum DEBIAN-PACKAGES.txt >> SHA256SUMS) +sha256sum /work/soloist.tar.gz > /work/out/SOLOIST-ARCHIVE-SHA256.txt +(cd /work/out && sha256sum SOLOIST-ARCHIVE-SHA256.txt >> SHA256SUMS) +tar -czf /output/runtime.tar.gz -C /work/out . +du -sh /work/out /output/runtime.tar.gz +INNER +chmod +x "$work/build.sh" +docker run --rm --platform linux/amd64 -v "$work:/output" debian:trixie-slim bash /output/build.sh +cp "$work/runtime.tar.gz" "$out" +printf 'Built %s\n' "$out" diff --git a/server/admin.mjs b/server/admin.mjs index ce4c009..0f1ded5 100644 --- a/server/admin.mjs +++ b/server/admin.mjs @@ -1,6 +1,7 @@ import { createHmac, timingSafeEqual } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { validateSettings } from './store.mjs'; +import { spotifyStatus, startSpotify, finishSpotify } from './spotify.mjs'; const assets = new Map([ ['/', ['index.html', 'text/html; charset=utf-8']], @@ -75,17 +76,28 @@ export async function handleAdmin(req, res, store) { return true; } - if (route !== '/api/admin' && route !== '/api/admin/settings' && route !== '/api/admin/history') return false; + if (route === '/api/spotify/callback' && req.method === 'GET') { + const params = new URL(req.url, 'http://localhost').searchParams; + let success = false; + try { await finishSpotify(params.get('state'), params.get('code')); success = true; } catch {} + res.writeHead(success ? 200 : 400, { ...securityHeaders, 'content-type': 'text/html; charset=utf-8' }); + res.end(success ? '
Spotify подключён. Вернитесь в пульт.
' : 'Не удалось подключить Spotify. Попробуйте снова из пульта.
'); + return true; + } + if (route !== '/api/admin' && route !== '/api/admin/settings' && route !== '/api/admin/history' && route !== '/api/spotify/start') return false; const auth = req.headers.authorization ?? ''; if (!auth.startsWith('tma ') || !verifyTelegram(auth.slice(4), process.env.TELEGRAM_BOT_TOKEN, process.env.TELEGRAM_OWNER_ID)) { json(res, 401, { error: 'telegram_auth' }); return true; } - if (route === '/api/admin' && req.method === 'GET') { + if (route === '/api/spotify/start' && req.method === 'POST') { + try { json(res, 200, { url: startSpotify() }); } catch { json(res, 503, { error: 'spotify_not_configured' }); } + } else if (route === '/api/admin' && req.method === 'GET') { json(res, 200, { settings: store.getSettings(), history: store.getHistory().reverse(), + spotify: spotifyStatus(), }); } else if (route === '/api/admin/settings' && req.method === 'PUT') { const settings = await readSettings(req); diff --git a/server/device.mjs b/server/device.mjs new file mode 100644 index 0000000..dd3ff5e --- /dev/null +++ b/server/device.mjs @@ -0,0 +1,58 @@ +import { randomUUID } from 'node:crypto'; +import WebSocket from 'ws'; +import { searchSpotify } from './spotify.mjs'; + +const spotifyUri = /^spotify:(track|artist|album|playlist|episode):[A-Za-z0-9]{22}$/; + +export function deviceTools(client, signal) { + const pending = new Map(); + signal.addEventListener('abort', () => { + for (const { reject, timeout } of pending.values()) { + clearTimeout(timeout); + reject(new Error('device_disconnected')); + } + pending.clear(); + }, { once: true }); + + function request(name, args) { + if (signal.aborted || client.readyState !== WebSocket.OPEN) throw new Error('device_disconnected'); + const id = randomUUID(); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pending.delete(id); + reject(new Error('device_tool_timeout')); + }, 17000); + pending.set(id, { resolve, reject, timeout }); + client.send(JSON.stringify({ type: 'device_tool', id, name, arguments: args })); + }); + } + + return { + accept(message) { + if (message?.type !== 'device_tool_result') return false; + const call = pending.get(message.id); + if (!call) return true; + clearTimeout(call.timeout); + pending.delete(message.id); + call.resolve(message.result ?? { error: 'empty_device_result' }); + return true; + }, + async execute(name, args) { + if (name !== 'spotify_play') return request(name, args); + let selected; + if (args.uri) { + if (!spotifyUri.test(args.uri)) return { error: 'invalid_spotify_uri' }; + selected = { uri: args.uri }; + } else { + try { + selected = (await searchSpotify(args.query, signal))[0]; + } catch { + return { error: 'spotify_search_unavailable', hint: 'Подключи Spotify в пульте Telegram и повтори запрос.' }; + } + } + if (!selected) return { error: 'spotify_not_found' }; + const result = await request(name, { uri: selected.uri }); + return { ...result, selection: selected }; + }, + }; +} diff --git a/server/providers.mjs b/server/providers.mjs index c767f81..9878083 100644 --- a/server/providers.mjs +++ b/server/providers.mjs @@ -1,4 +1,5 @@ import WebSocket from 'ws'; +import { tools, validateTool } from './tools.mjs'; const env = process.env; const sttUrl = env.ELEVEN_STT_URL || 'wss://api.elevenlabs.io/v1/speech-to-text/realtime'; @@ -64,31 +65,79 @@ export async function openTts(settings, signal, onAudio) { return { ws, done, text(text) { if (ws.readyState !== WebSocket.OPEN) throw new Error('tts_closed'); ws.send(JSON.stringify(flash ? { text } : { inputs: [{ text, voice_id: settings.voiceId }] })); }, finish() { if (ws.readyState !== WebSocket.OPEN) throw new Error('tts_closed'); ws.send(JSON.stringify(flash ? { text: '' } : { close_socket: true })); } }; } -export async function streamAnswer(settings, history, userText, signal, onText) { +export async function streamAnswer(settings, history, userText, signal, onText, executeDeviceTool) { if (env.LLM_MODE === 'echo') { onText(userText); return userText; } if (!env.OPENROUTER_API_KEY) throw new Error('llm_config'); - const messages = [{ role: 'system', content: settings.systemPrompt }, ...history.slice(-10).flatMap(t => [{ role: 'user', content: t.user }, { role: 'assistant', content: t.assistant }]), { role: 'user', content: userText }]; - const response = await fetch(llmUrl, { method: 'POST', signal, headers: { Authorization: `Bearer ${env.OPENROUTER_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: settings.llmModel, messages, stream: true, max_tokens: 512 }) }); - if (!response.ok || !response.body) throw new Error('llm_error'); - let buffer = '', answer = '', finished = false; - const decoder = new TextDecoder(); - const consume = event => { - const payload = event.split(/\r?\n/).filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n'); - if (!payload) return; - if (payload === '[DONE]') { finished = true; return; } - const message = JSON.parse(payload); - if (message.error) throw new Error('llm_error'); - const content = message.choices?.[0]?.delta?.content; - if (typeof content === 'string' && content) { answer += content; if (answer.length > 12000) throw new Error('llm_limit'); onText(content); } - }; - for await (const chunk of response.body) { - buffer += decoder.decode(chunk, { stream: true }); - if (buffer.length > 65536) throw new Error('llm_limit'); - let match; - while ((match = /\r?\n\r?\n/.exec(buffer))) { consume(buffer.slice(0, match.index)); buffer = buffer.slice(match.index + match[0].length); } + const moscowDate = new Intl.DateTimeFormat('ru-RU', { timeZone: 'Europe/Moscow', dateStyle: 'long' }).format(new Date()); + const systemPrompt = `${settings.systemPrompt}\nСегодня ${moscowDate} (Москва). Используй веб-поиск для актуальных сведений и когда требуется проверить сомнительные факты. Вызывай инструменты без вступительных фраз. Подтверждай воспроизведение музыки только после успешного результата инструмента. Отвечай для озвучивания без 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 }]; + let answer = '', totalCalls = 0; + for (let round = 0; round < 4; round++) { + const response = await fetch(llmUrl, { method: 'POST', signal, headers: { Authorization: `Bearer ${env.OPENROUTER_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: settings.llmModel, messages, tools, stream: true, max_tokens: 1200, ...(settings.llmModel === 'anthropic/claude-haiku-5.5' ? { reasoning: { enabled: false, effort: 'low' } } : {}) }) }); + if (!response.ok || !response.body) throw new Error('llm_error'); + let buffer = '', content = '', done = false, finishReason; + const calls = new Map(); + const decoder = new TextDecoder(); + const consume = event => { + const payload = event.split(/\r?\n/).filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n'); + if (!payload) return; + if (payload === '[DONE]') { done = true; return; } + const message = JSON.parse(payload); + if (message.error) throw new Error('llm_error'); + const choice = message.choices?.[0]; + if (!choice) return; + if (choice.finish_reason) finishReason = choice.finish_reason; + const delta = choice.delta || {}; + if (typeof delta.content === 'string' && delta.content) { + const prefix = !content && answer && !/\s$/.test(answer) && !/^\s/.test(delta.content) ? ' ' : ''; + content += delta.content; answer += prefix + delta.content; + if (answer.length > 12000) throw new Error('llm_limit'); + onText(prefix + delta.content); + } + for (const part of delta.tool_calls || []) { + if (!Number.isInteger(part.index) || part.index < 0 || part.index >= 4) throw new Error('llm_limit'); + const call = calls.get(part.index) || { id: '', type: 'function', function: { name: '', arguments: '' } }; + if (part.id) call.id += part.id; + if (part.type && part.type !== 'function') throw new Error('llm_error'); + if (part.function?.name) call.function.name += part.function.name; + if (part.function?.arguments) call.function.arguments += part.function.arguments; + if (call.id.length > 512 || call.function.name.length > 128 || call.function.arguments.length > 8192) throw new Error('llm_limit'); + calls.set(part.index, call); + } + }; + for await (const chunk of response.body) { + buffer += decoder.decode(chunk, { stream: true }); + if (buffer.length > 65536) throw new Error('llm_limit'); + let match; + while ((match = /\r?\n\r?\n/.exec(buffer))) { consume(buffer.slice(0, match.index)); buffer = buffer.slice(match.index + match[0].length); } + } + buffer += decoder.decode(); + if (buffer.trim()) consume(buffer); + if (!done || !finishReason) throw new Error('llm_incomplete'); + if (!calls.size) { + if (finishReason !== 'stop' || !answer.trim()) throw new Error('llm_incomplete'); + return answer; + } + if (finishReason !== 'tool_calls') throw new Error('llm_incomplete'); + const ordered = [...calls.entries()].sort((a, b) => a[0] - b[0]).map(([, call]) => call); + if ((totalCalls += ordered.length) > 4 || round === 3 || ordered.some(c => !c.id || !c.function.name)) throw new Error('llm_limit'); + messages.push({ role: 'assistant', content: content || null, tool_calls: ordered }); + for (const call of ordered) { + if (signal.aborted) throw new Error('aborted'); + let result; + try { + const args = validateTool(call.function.name, JSON.parse(call.function.arguments)); + if (typeof executeDeviceTool !== 'function') throw new Error('device_unavailable'); + result = await executeDeviceTool(call.function.name, args); + if (signal.aborted) throw new Error('aborted'); + result = JSON.stringify(result ?? { ok: true }); + if (result.length > 8192) result = JSON.stringify({ error: 'result_too_large' }); + } catch (error) { + if (signal.aborted) throw error; + result = JSON.stringify({ error: 'tool_failed' }); + } + messages.push({ role: 'tool', tool_call_id: call.id, content: result }); + } } - buffer += decoder.decode(); - if (buffer.trim()) consume(buffer); - if (!finished || !answer.trim()) throw new Error('llm_incomplete'); - return answer; + throw new Error('llm_limit'); } diff --git a/server/public/app.js b/server/public/app.js index f5e04d2..e3b2156 100644 --- a/server/public/app.js +++ b/server/public/app.js @@ -8,6 +8,8 @@ const showSettings = document.querySelector('#show-settings'); const clear = document.querySelector('#clear'); const form = document.querySelector('#form'); const turns = document.querySelector('#turns'); +const spotifyStatus = document.querySelector('#spotify-status'); +const spotifyConnect = document.querySelector('#spotify-connect'); const dateFormat = new Intl.DateTimeFormat('ru', { dateStyle: 'medium', timeStyle: 'short' }); @@ -61,7 +63,25 @@ showHistory.onclick = async () => { status.textContent = error.message; } }; -showSettings.onclick = () => selectTab(false); +function renderSpotify(spotify) { + spotifyStatus.textContent = !spotify.configured ? 'Spotify пока не настроен на сервере.' : spotify.connected ? 'Аккаунт подключён.' : 'Подключи аккаунт для управления музыкой.'; + spotifyConnect.disabled = !spotify.configured; + spotifyConnect.textContent = spotify.connected ? 'Подключить другой аккаунт' : 'Подключить Spotify'; +} +showSettings.onclick = async () => { + selectTab(false); + try { renderSpotify((await api()).spotify); } catch (error) { status.textContent = error.message; } +}; +spotifyConnect.onclick = async () => { + spotifyConnect.disabled = true; + try { + const response = await fetch('/api/spotify/start', { method: 'POST', headers: { Authorization: 'tma ' + telegram.initData } }); + if (!response.ok) throw new Error('Не удалось начать подключение Spotify.'); + const { url } = await response.json(); + telegram.openLink(url); + } catch (error) { status.textContent = error.message; } + finally { spotifyConnect.disabled = false; } +}; form.onsubmit = async (event) => { event.preventDefault(); @@ -101,6 +121,7 @@ if (!telegram?.initData) { const data = await api(); for (const [name, value] of Object.entries(data.settings)) form.elements.namedItem(name).value = value; renderTurns(data.history); + renderSpotify(data.spotify); status.textContent = ''; admin.hidden = false; } catch (error) { diff --git a/server/public/index.html b/server/public/index.html index 26b54f0..263471b 100644 --- a/server/public/index.html +++ b/server/public/index.html @@ -26,6 +26,7 @@Проверяем подключение…