Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 7 additions & 34 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
#
# 启动顺序:
# 1) pnpm dev:api # 读 .env,监听 http://localhost:8787(含 WS /stt-realtime)
# 2) pnpm dev:playground # Vite 把 /stt /stt-realtime /llm 代理到 8787
# 2) pnpm dev:playground # Vite 把 /stt-realtime /llm 代理到 8787

# ── 代理端口 ─────────────────────────────────────────────
PORT=8787
Expand Down Expand Up @@ -39,39 +39,12 @@ PORT=8787
# PLAYGROUND_API_ORIGIN=http://localhost:8787

# ── 语音转写 (STT) ────────────────────────────────────────
# 三个 batch provider(可同时配置,playground 从 GET /stt/providers 切换):
# - openrouter :云端,JSON → /audio/transcriptions
# - openai :Whisper 形 multipart /v1/audio/transcriptions(本地 mlx-qwen3-asr 等,见 ADR 0002)
# - dashscope :阿里云百炼 batch ASR(OpenAI 兼容 chat/completions + input_audio)
# - dashscope-realtime:同 key + STT_DASHSCOPE_WS_URL → WS /stt-realtime(ADR 0004)
# STT_<PROVIDER>_MODEL 不填时回退到注册默认值。
STT_ACTIVE=openrouter
# 生产 realtime 与 batch 并行;batch provider 单独冻结。
# STT_BATCH_ACTIVE=dashscope
STT_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
STT_OPENROUTER_API_KEY=sk-or-xxxxxxxxxxxxxxxx
STT_OPENROUTER_MODEL=openai/gpt-4o-transcribe

# 本地 Qwen3-ASR(Apple Silicon,mlx-qwen3-asr serve 默认监听 8765):
# pip install "mlx-qwen3-asr[serve]"
# mlx-qwen3-asr serve --api-key $(openssl rand -hex 16)
# 仍经 apps/api /stt 代理转发;浏览器发 /stt?provider=openai,key 留在服务端。
# 需 apps/api 与 mlx-qwen3-asr 同机(本地 dev)。切到本地:STT_ACTIVE=openai,或浏览器加 ?provider=openai。
# STT_ACTIVE=openai
# STT_OPENAI_BASE_URL=http://localhost:8765/v1
# STT_OPENAI_API_KEY=本地 serve 启动时生成的 key
# STT_OPENAI_MODEL=Qwen3-ASR-1.7B

# 阿里云百炼 Qwen3-ASR batch(将 {WorkspaceId} 换成业务空间 ID):
# STT_ACTIVE=dashscope
# STT_DASHSCOPE_BASE_URL=https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/compatible-mode/v1
# STT_DASHSCOPE_API_KEY=sk-xxxxxxxx
# STT_DASHSCOPE_MODEL=qwen3-asr-flash
#
# Realtime(WS /stt-realtime?provider=dashscope-realtime,ADR 0004):
# 复用 STT_DASHSCOPE_API_KEY;另需 WS_URL。浏览器只连同源代理,不直连上游。
# STT_DASHSCOPE_WS_URL=wss://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api-ws/v1/realtime
# STT_DASHSCOPE_REALTIME_MODEL=qwen3-asr-flash-realtime
# Realtime only(WS /stt-realtime?provider=dashscope-realtime,ADR 0004)。
# 浏览器只连同源代理,不直连上游。
STT_ACTIVE=dashscope-realtime
STT_DASHSCOPE_API_KEY=sk-xxxxxxxx
STT_DASHSCOPE_WS_URL=wss://{WorkspaceId}.ap-northeast-1.maas.aliyuncs.com/api-ws/v1/realtime
STT_DASHSCOPE_REALTIME_MODEL=qwen3-asr-flash-realtime

# ── 大语言模型 (LLM) ─────────────────────────────────────
# 走 xsai(OpenAI 兼容)。provider 与 STT 可不同。
Expand Down
44 changes: 33 additions & 11 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,13 @@ jobs:
CN_SERVER_HOST: ${{ secrets.CN_SERVER_HOST }}
CN_SERVER_USER: ${{ secrets.CN_SERVER_USER }}
CN_RELAY_ORIGIN: ${{ secrets.CN_RELAY_ORIGIN }}
JP_STT_DASHSCOPE_API_KEY: ${{ secrets.JP_STT_DASHSCOPE_API_KEY }}
JP_STT_DASHSCOPE_WS_URL: ${{ secrets.JP_STT_DASHSCOPE_WS_URL }}
run: |
for secret_name in \
DEPLOY_SSH_KEY SERVER_HOST SERVER_USER \
CN_DEPLOY_SSH_KEY CN_SERVER_HOST CN_SERVER_USER CN_RELAY_ORIGIN; do
CN_DEPLOY_SSH_KEY CN_SERVER_HOST CN_SERVER_USER CN_RELAY_ORIGIN \
JP_STT_DASHSCOPE_API_KEY JP_STT_DASHSCOPE_WS_URL; do
if [ -z "${!secret_name}" ]; then
echo "::error::Missing Actions secret: ${secret_name}"
exit 1
Expand All @@ -49,26 +52,44 @@ jobs:
chmod 600 ~/.ssh/id_ed25519_primary ~/.ssh/id_ed25519_cn
ssh-keyscan -H "$SERVER_HOST" >> ~/.ssh/known_hosts
ssh-keyscan -H "$CN_SERVER_HOST" >> ~/.ssh/known_hosts
- name: Configure relay origin on primary
- name: Configure primary routes and providers
env:
SERVER_HOST: ${{ secrets.SERVER_HOST }}
SERVER_USER: ${{ secrets.SERVER_USER }}
CN_RELAY_ORIGIN: ${{ secrets.CN_RELAY_ORIGIN }}
JP_STT_DASHSCOPE_API_KEY: ${{ secrets.JP_STT_DASHSCOPE_API_KEY }}
JP_STT_DASHSCOPE_WS_URL: ${{ secrets.JP_STT_DASHSCOPE_WS_URL }}
run: |
case "$CN_RELAY_ORIGIN" in
http://*|https://*) ;;
*) echo "::error::CN_RELAY_ORIGIN must be an absolute HTTP(S) origin"; exit 1 ;;
esac
printf '%s\n' "$CN_RELAY_ORIGIN" \
case "$JP_STT_DASHSCOPE_WS_URL" in
wss://*.ap-northeast-1.maas.aliyuncs.com/api-ws/v1/realtime) ;;
*) echo "::error::JP_STT_DASHSCOPE_WS_URL must use the Alibaba Cloud Tokyo realtime endpoint"; exit 1 ;;
esac
printf '%s\n%s\n%s\n' \
"$CN_RELAY_ORIGIN" \
"$JP_STT_DASHSCOPE_API_KEY" \
"$JP_STT_DASHSCOPE_WS_URL" \
| ssh -i ~/.ssh/id_ed25519_primary "${SERVER_USER}@${SERVER_HOST}" '
set -eu
IFS= read -r origin
escaped=$(printf "%s" "$origin" | sed "s/[|&]/\\\\&/g")
if grep -q "^RELAY_CN_ORIGIN=" /opt/kibotalk/.env; then
sed -i "s|^RELAY_CN_ORIGIN=.*|RELAY_CN_ORIGIN=${escaped}|" /opt/kibotalk/.env
else
printf "RELAY_CN_ORIGIN=%s\n" "$origin" >> /opt/kibotalk/.env
fi
IFS= read -r stt_key
IFS= read -r stt_url
set_env() {
name=$1
value=$2
escaped=$(printf "%s" "$value" | sed "s/[|&]/\\\\&/g")
if grep -q "^${name}=" /opt/kibotalk/.env; then
sed -i "s|^${name}=.*|${name}=${escaped}|" /opt/kibotalk/.env
else
printf "%s=%s\n" "$name" "$value" >> /opt/kibotalk/.env
fi
}
set_env RELAY_CN_ORIGIN "$origin"
set_env STT_DASHSCOPE_API_KEY "$stt_key"
set_env STT_DASHSCOPE_WS_URL "$stt_url"
chmod 600 /opt/kibotalk/.env
'
- name: Validate server environments
Expand All @@ -84,7 +105,8 @@ jobs:
for name in \
RELAY_PRIMARY_ORIGIN RELAY_CN_ORIGIN RELAY_CN_ENABLED \
RELAY_TOKEN_PRIVATE_KEY RELAY_TOKEN_PUBLIC_KEY RELAY_NODE_SECRET \
STT_ACTIVE STT_BATCH_ACTIVE LLM_ACTIVE; do
STT_ACTIVE STT_DASHSCOPE_API_KEY STT_DASHSCOPE_WS_URL \
LLM_ACTIVE; do
grep -q "^${name}=." /opt/kibotalk/.env
done
'
Expand All @@ -94,7 +116,7 @@ jobs:
for name in \
RELAY_PRIMARY_ORIGIN RELAY_ACCEPT_NEW_SESSIONS \
RELAY_TOKEN_PUBLIC_KEY RELAY_NODE_SECRET \
STT_ACTIVE STT_BATCH_ACTIVE LLM_ACTIVE; do
STT_ACTIVE LLM_ACTIVE; do
grep -q "^${name}=." /opt/kibotalk-relay/.env
done
relay_port=$(sed -n "s/^RELAY_HTTP_PORT=//p" /opt/kibotalk-relay/.env)
Expand Down
26 changes: 12 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,14 @@ update the spec — don't silently drift.
Client orchestration + thin proxy (ADR 0001). The browser runs the pipeline;
`apps/api` is a stateless Hono proxy that hides provider keys and forwards STT/LLM.

**STT modes** (ADR 0004): **batch** (`POST /stt`) and **realtime**
(`WS /stt-realtime`) run in parallel—not a migration. Local VAD + speaker
verification own turn boundaries (`pauseMs`); realtime uses Manual commit
(no server VAD). Timeline may show partial drafts; formal turns + LLM fire
only on finalized transcript.
**STT** (ADR 0004): **realtime only** via `WS /stt-realtime` (DashScope
`qwen3-asr-flash-realtime`). Local VAD + speaker verification own turn
boundaries (`pauseMs`); upstream uses Manual commit (no server VAD). Timeline
may show partial drafts; formal turns + LLM fire only on finalized transcript.

```
apps/
api/ Hono proxy: /stt, /stt-realtime (WS), /llm (SSE). Keys server-side only.
api/ Hono proxy: /stt-realtime (WS), /llm (SSE). Keys server-side only.
playground/ Vite + React dev panel (Chinese UI) for testing each layer
(声纹页 covers enrollment + free-speech verify / threshold tuning).
web/ PWA shell (not yet built).
Expand All @@ -49,8 +48,8 @@ packages/
prompts/ Reply-suggestion prompts (Velin TSX → markdown).
speaker/ Speaker verification (wavlm-base-plus-sv, WASM + IndexedDB);
`verify` returns raw `similarity` plus label `confidence`.
stt/ Provider-agnostic STT: batch adapters + DashScope realtime mapper
helpers (server-side). Providers declare mode batch|realtime.
stt/ DashScope realtime mapper helpers (server-side); `apps/api` relays
`WS /stt-realtime` only.
pipeline/ Conversation store + turn state machine.
ui/ shadcn/ui primitives on Tailwind v4 (shared).
app-shared/ Shared client types/config shell.
Expand All @@ -62,29 +61,28 @@ These are spec-named choices. **Do not rewrite or substitute them** with hand-ro

- **LLM → `xsai`** (`@xsai/stream-text`). Never hand-roll fetch/SSE for LLM. An official `xsai` skill is installed at `.agents/skills/xsai/` — read its `SKILL.md` + `references/` before touching `packages/llm`.
- **Prompts → Velin** (`@velin-dev/core-react`). Render TSX components to markdown strings; don't template with raw string concat.
- **STT → provider-agnostic factory** in `packages/stt`, reached via the `apps/api` `/stt` proxy. Local ASR (`mlx-qwen3-asr`) also goes through the proxy (`?provider=openai`), **never browser-direct** (ADR 0002).
- **STT → DashScope realtime** via `packages/stt` mapper helpers, reached through the `apps/api` `WS /stt-realtime` relay. **Never browser-direct** to upstream (ADR 0004).
- **VAD → Silero** via `@huggingface/transformers`. v6.2 needs a 64-sample context prepended to each 512-sample chunk (576 input), carried across calls; v5 takes 512 raw. See `apps/playground/src/audio/silero-vad.ts`.
- **Speaker → `Xenova/wavlm-base-plus-sv`** via transformers.js in a Web Worker; embeddings persist in IndexedDB.

## Audio pipeline specifics

- **Sample rate**: 16 kHz mono PCM everywhere (VAD, STT uploads, speaker embeddings).
- **Sample rate**: 16 kHz mono PCM everywhere (VAD, realtime STT uplink, speaker embeddings).
- **VAD chunk size**: 512 samples (32 ms) per `processAudio` call (`packages/audio/src/vad.ts` `newBufferSize`). Silero v6.2 prepends 64-sample context → 576 input; v5 takes 512 raw. See `docs/solutions/silero-vad-v6-context-frame.md`.
- **STT upload format**: WAV 16 kHz mono PCM via `encodeWav` (`packages/audio/src`). The `/stt` proxy forwards the WAV body as-is.
- **Speaker embeddings**: computed in a Web Worker (`apps/playground/src/audio/speaker-worker.ts`), persisted in IndexedDB. Don't run the WASM model on the main thread.
- **Segment aggregation / TurnGate** (`packages/audio/src/aggregator.ts`): sits between VAD (+ speaker verification) and batch `ingestSegment` or realtime `append`/`commit`. Accumulates same-speaker speech and flushes on `pauseMs`, `maxMs` (speech only), or speaker change. PCM is direct-concatenated (no gap fill). Spec §2.4 / ADR 0004. Pipeline fires LLM per finalized turn and does NOT wait on pause itself.
- **Segment aggregation / TurnGate** (`packages/audio/src/aggregator.ts`): sits between VAD (+ speaker verification) and realtime `append`/`commit` → `ingestFinalizedTurn`. Merges same-speaker fragment transcripts and flushes on `pauseMs`, `maxMs` (speech only), or speaker change. Spec §2.4 / ADR 0004. Pipeline fires LLM per finalized turn and does NOT wait on pause itself.

## Conventions

- **Keys in env, never client.** All provider keys/config live in `.env` (see `.env.example`), loaded by `apps/api`. Naming: `<SCOPE>_<PROVIDER>_<FIELD>` (e.g. `LLM_OPENROUTER_API_KEY`, `STT_OPENAI_BASE_URL`).
- **Keys in env, never client.** All provider keys/config live in `.env` (see `.env.example`), loaded by `apps/api`. Naming: `<SCOPE>_<PROVIDER>_<FIELD>` (e.g. `LLM_OPENROUTER_API_KEY`, `STT_DASHSCOPE_API_KEY`).
- **Pure functions; no new classes** unless the framework/API requires it. Imports at top. TS unions: exhaustive `switch`.
- **Full words.** No obscure abbreviations; only use ones common in software.
- **Smallest correct diff.** Only change what was asked. No drive-by refactor, tests, or docs unless asked. When you touch code, small progressive refactors alongside the change are welcome.
- **Reuse and extend** existing functions/modules; do not duplicate similar logic. Before implementing a feature (a selector, a hook, a helper, a card), grep the repo for it first — chances are someone already wrote it. The moment you notice a second copy of something, extract it into a shared module/component and have both callers use it. Examples already in the repo: `SttProviderSelect` + `useSttProviders` + `sttUrl` (`apps/playground/src/SttProviderSelect.tsx`, used by both the VAD panel and the direct-API panel), `padBuffer` / `encodeWav` (`packages/audio`), `createSegmentAggregator` (`packages/audio/aggregator`).
- **No backward-compatibility guards.** If a rename/breakage is needed, do it directly and update callers in the same change.
- **Playground UI is Chinese** — labels, examples, and sample content in Chinese.
- **Tailwind v4 + shadcn/ui** for all UI (playground included). Shared primitives in `packages/ui`(Button、Card、Badge、Input、Textarea、Label、Tabs、Separator、Accordion、Collapsible、Dialog、DropdownMenu、Popover、Progress、ScrollArea、Select、Sheet、Skeleton、Slider、Switch、Tooltip、Toaster)。缺组件先 `shadcn add` 进该包并导出;**改样式改源组件 / token,不要平行重造**。
- **Shared playground config lives in one Zustand store** (`apps/playground/src/config-store.ts`, `useConfig`) — the React analog of a Pinia store. VAD/ASR/merge/speaker knobs, language prefs (`uiLang` / `conversationLang` / `level` / `languagesConfirmed`, persisted; `meaningLang` is derived from `uiLang` in the session snapshot), and selectors (provider, VAD model, transcribe mode) are shared across the VAD panel and the live session: change one on a tab and it's already aligned on the other. Subscribe per-field (`useConfig(s => s.field)`); in async callbacks read `useConfig.getState()`. Stage-grouped field components live in `apps/playground/src/components/ConfigFields.tsx` (`VadParamsFields`, `AsrPadFields`, `MergeParamsFields`, `LanguagePrefsFields`, `VadModelSelect`, `TranscribeModeSelect`, `TranscribeProviderSelect`, `NumberField`) — reuse these instead of re-declaring the same knobs.
- **Shared playground config lives in one Zustand store** (`apps/playground/src/config-store.ts`, `useConfig`) — the React analog of a Pinia store. VAD/merge/speaker knobs, language prefs (`uiLang` / `conversationLang` / `level` / `languagesConfirmed`, persisted; `meaningLang` is derived from `uiLang` in the session snapshot), and selectors (VAD model, STT provider) are shared across the VAD panel and the live session: change one on a tab and it's already aligned on the other. Subscribe per-field (`useConfig(s => s.field)`); in async callbacks read `useConfig.getState()`. Stage-grouped field components live in `apps/playground/src/components/ConfigFields.tsx` (`VadParamsFields`, `MergeParamsFields`, `LanguagePrefsFields`, `VadModelSelect`, `TranscribeProviderSelect`, `NumberField`) — reuse these instead of re-declaring the same knobs.

## Commands

Expand Down
Loading
Loading