diff --git a/.env.example b/.env.example index 4b194c3..44a7bce 100644 --- a/.env.example +++ b/.env.example @@ -9,26 +9,25 @@ # ── 代理端口 ───────────────────────────────────────────── PORT=8787 -# ── 双节点生产拓扑 ──────────────────────────────────────── -# 日本主服务器: +# ── 生产节点拓扑 ───────────────────────────────────────── +# 日本主服务器(当前生产只有这一台): # SERVER_ROLE=primary # RELAY_NODE_ID=jp-primary # RELAY_PRIMARY_ORIGIN=https://app.kibotalk.app -# RELAY_CN_ORIGIN=http://123.99.200.156:8443 -# RELAY_CN_NODE_ID=cn-relay -# RELAY_CN_ENABLED=true +# RELAY_NODES_JSON=[] # -# 国内中转服务器: +# 可选中转服务器(例如香港或新加坡): # SERVER_ROLE=relay -# RELAY_NODE_ID=cn-relay +# RELAY_NODE_ID=sg-relay # RELAY_PRIMARY_ORIGIN=https://app.kibotalk.app # RELAY_OUTBOX_PATH=/app/data/usage-outbox.json # RELAY_ACCEPT_NEW_SESSIONS=true -# RELAY_HTTP_PORT=8443 +# 主服务器用 JSON 发布一个或多个中转节点: +# RELAY_NODES_JSON=[{"id":"sg-relay","origin":"https://sg-relay.kibotalk.app"}] # -# Ed25519:日本保存私钥,两台都保存公钥。值可以是 PEM,或 PEM 的 base64。 +# Ed25519:主服务器保存私钥,所有节点保存公钥。值可以是 PEM,或 PEM 的 base64。 # RELAY_TOKEN_PRIVATE_KEY=replace-on-primary-only -# RELAY_TOKEN_PUBLIC_KEY=replace-on-both-nodes +# RELAY_TOKEN_PUBLIC_KEY=replace-on-all-nodes # 节点间心跳和用量回传凭据(与用户/提供商密钥分离): # RELAY_NODE_SECRET=replace-with-a-long-random-secret diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 86882ac..040deee 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -26,13 +26,12 @@ jobs: CN_DEPLOY_SSH_KEY: ${{ secrets.CN_DEPLOY_SSH_KEY }} 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 \ + CN_DEPLOY_SSH_KEY CN_SERVER_HOST CN_SERVER_USER \ JP_STT_DASHSCOPE_API_KEY JP_STT_DASHSCOPE_WS_URL; do if [ -z "${!secret_name}" ]; then echo "::error::Missing Actions secret: ${secret_name}" @@ -49,32 +48,26 @@ jobs: install -d -m 700 ~/.ssh printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519_primary printf '%s\n' "$CN_DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519_cn - chmod 600 ~/.ssh/id_ed25519_primary ~/.ssh/id_ed25519_cn + chmod 600 ~/.ssh/id_ed25519_primary + chmod 600 ~/.ssh/id_ed25519_cn ssh-keyscan -H "$SERVER_HOST" >> ~/.ssh/known_hosts ssh-keyscan -H "$CN_SERVER_HOST" >> ~/.ssh/known_hosts - 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 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" \ + printf '%s\n%s\n' \ "$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 IFS= read -r stt_key IFS= read -r stt_url set_env() { @@ -87,7 +80,7 @@ jobs: printf "%s=%s\n" "$name" "$value" >> /opt/kibotalk/.env fi } - set_env RELAY_CN_ORIGIN "$origin" + set_env RELAY_NODES_JSON "[]" set_env STT_DASHSCOPE_API_KEY "$stt_key" set_env STT_DASHSCOPE_WS_URL "$stt_url" chmod 600 /opt/kibotalk/.env @@ -96,36 +89,18 @@ jobs: env: SERVER_HOST: ${{ secrets.SERVER_HOST }} SERVER_USER: ${{ secrets.SERVER_USER }} - CN_SERVER_HOST: ${{ secrets.CN_SERVER_HOST }} - CN_SERVER_USER: ${{ secrets.CN_SERVER_USER }} run: | ssh -i ~/.ssh/id_ed25519_primary "${SERVER_USER}@${SERVER_HOST}" ' set -eu test -s /opt/kibotalk/.env for name in \ - RELAY_PRIMARY_ORIGIN RELAY_CN_ORIGIN RELAY_CN_ENABLED \ + RELAY_PRIMARY_ORIGIN RELAY_NODES_JSON \ RELAY_TOKEN_PRIVATE_KEY RELAY_TOKEN_PUBLIC_KEY RELAY_NODE_SECRET \ STT_ACTIVE STT_DASHSCOPE_API_KEY STT_DASHSCOPE_WS_URL \ LLM_ACTIVE; do grep -q "^${name}=." /opt/kibotalk/.env done ' - ssh -i ~/.ssh/id_ed25519_cn "${CN_SERVER_USER}@${CN_SERVER_HOST}" ' - set -eu - test -s /opt/kibotalk-relay/.env - for name in \ - RELAY_PRIMARY_ORIGIN RELAY_ACCEPT_NEW_SESSIONS \ - RELAY_TOKEN_PUBLIC_KEY RELAY_NODE_SECRET \ - 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) - relay_port=${relay_port:-8443} - case "$relay_port" in - 8443|9443) ;; - *) echo "RELAY_HTTP_PORT must be 8443 or 9443" >&2; exit 1 ;; - esac - ' - uses: pnpm/action-setup@v4 with: version: 10.33.0 @@ -141,22 +116,15 @@ jobs: - name: Build API image run: | docker build --platform linux/amd64 --tag "kibotalk-api:${GITHUB_SHA}" . - docker build --platform linux/amd64 --target relay \ - --tag "kibotalk-relay-api:${GITHUB_SHA}" . docker build --platform linux/amd64 --tag kibotalk-caddy:2.10 --file infra/production/Caddy.Dockerfile . docker build --platform linux/amd64 --tag kibotalk-postgres:17 --file infra/production/Postgres.Dockerfile . - touch infra/production/.env infra/relay/.env + touch infra/production/.env ( cd infra/production POSTGRES_PASSWORD=validation docker compose -f compose.yaml \ config --quiet --no-env-resolution --no-path-resolution ) - ( - cd infra/relay - RELAY_HTTP_PORT=8443 docker compose -f compose.yaml \ - config --quiet --no-env-resolution --no-path-resolution - ) - rm infra/production/.env infra/relay/.env + rm infra/production/.env docker run --rm \ --volume "$PWD/infra/production/Caddyfile:/etc/caddy/Caddyfile:ro" \ kibotalk-caddy:2.10 \ @@ -166,9 +134,6 @@ jobs: kibotalk-caddy:2.10 \ kibotalk-postgres:17 \ | gzip -1 > /tmp/kibotalk-images.tar.gz - docker save \ - "kibotalk-relay-api:${GITHUB_SHA}" \ - | gzip -1 > /tmp/kibotalk-relay-images.tar.gz - name: Stage on-device model mirror run: | pnpm --filter @kibotalk/desktop download-models @@ -182,17 +147,6 @@ jobs: scp -i ~/.ssh/id_ed25519_primary /tmp/kibotalk-images.tar.gz "${SERVER_USER}@${SERVER_HOST}:/opt/kibotalk/incoming/kibotalk-images.tar.gz" scp -i ~/.ssh/id_ed25519_primary /tmp/kibotalk-models.tar.gz "${SERVER_USER}@${SERVER_HOST}:/opt/kibotalk/incoming/kibotalk-models.tar.gz" scp -i ~/.ssh/id_ed25519_primary infra/production/compose.yaml infra/production/Caddyfile "${SERVER_USER}@${SERVER_HOST}:/opt/kibotalk/" - - name: Upload relay payload - env: - CN_SERVER_HOST: ${{ secrets.CN_SERVER_HOST }} - CN_SERVER_USER: ${{ secrets.CN_SERVER_USER }} - run: | - ssh -i ~/.ssh/id_ed25519_cn "${CN_SERVER_USER}@${CN_SERVER_HOST}" \ - "mkdir -p /opt/kibotalk-relay/incoming" - scp -i ~/.ssh/id_ed25519_cn /tmp/kibotalk-relay-images.tar.gz \ - "${CN_SERVER_USER}@${CN_SERVER_HOST}:/opt/kibotalk-relay/incoming/kibotalk-images.tar.gz" - scp -i ~/.ssh/id_ed25519_cn infra/relay/compose.yaml \ - "${CN_SERVER_USER}@${CN_SERVER_HOST}:/opt/kibotalk-relay/" - name: Activate release env: SERVER_HOST: ${{ secrets.SERVER_HOST }} @@ -220,27 +174,19 @@ jobs: fi docker compose --env-file .env -f compose.yaml up -d --pull never " - - name: Activate relay release + - name: Deactivate legacy China relay env: CN_SERVER_HOST: ${{ secrets.CN_SERVER_HOST }} CN_SERVER_USER: ${{ secrets.CN_SERVER_USER }} run: | - # GITHUB_SHA must expand on the runner before this script reaches the server. - # shellcheck disable=SC2029 - ssh -i ~/.ssh/id_ed25519_cn "${CN_SERVER_USER}@${CN_SERVER_HOST}" " + ssh -i ~/.ssh/id_ed25519_cn "${CN_SERVER_USER}@${CN_SERVER_HOST}" ' set -eu - gzip -dc /opt/kibotalk-relay/incoming/kibotalk-images.tar.gz | docker load - cd /opt/kibotalk-relay - if grep -q '^KIBOTALK_RELAY_IMAGE=' .env; then - sed -i 's|^KIBOTALK_RELAY_IMAGE=.*|KIBOTALK_RELAY_IMAGE=kibotalk-relay-api:${GITHUB_SHA}|' .env - else - printf '\nKIBOTALK_RELAY_IMAGE=kibotalk-relay-api:${GITHUB_SHA}\n' >> .env + if [ -f /opt/kibotalk-relay/compose.yaml ]; then + cd /opt/kibotalk-relay + docker compose --env-file .env -f compose.yaml down fi - docker compose --env-file .env -f compose.yaml up -d --pull never - " + ' - name: Verify deployment - env: - CN_RELAY_ORIGIN: ${{ secrets.CN_RELAY_ORIGIN }} run: | for _attempt in $(seq 1 24); do if curl --fail --silent --show-error https://app.kibotalk.app/health \ @@ -253,11 +199,7 @@ jobs: && curl --fail --silent --show-error https://kibotalk.app/en/ >/dev/null \ && test "$(curl --silent --output /dev/null --write-out '%{redirect_url}' https://www.kibotalk.app/)" = "https://kibotalk.app/" \ && curl --fail --silent --show-error https://advx.kibotalk.app/ \ - | grep --quiet '正在清理旧版网页数据' \ - && curl --fail --silent --show-error "${CN_RELAY_ORIGIN}/health" \ - | grep --quiet '"ok":true' \ - && curl --fail --silent --show-error "${CN_RELAY_ORIGIN}/api/latency" \ - | grep --quiet '"nodeId":"cn-relay"'; then + | grep --quiet '正在清理旧版网页数据'; then exit 0 fi sleep 5 diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index dc98c59..50b7000 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -55,7 +55,7 @@ import { releaseRelayLlm, } from './relay-session-state' import { providerHealthy } from './provider-health' -import { serverRole } from './server-role' +import { relayNodeId, serverRole } from './server-role' export const app = new Hono() const role = serverRole() @@ -135,7 +135,7 @@ app.get('/health', async (context) => { return context.json({ ok: providerHealthy(), role, - nodeId: process.env.RELAY_NODE_ID ?? 'cn-relay', + nodeId: relayNodeId(), providers: providerHealthy() ? 'ok' : 'error', version: process.env.APP_VERSION ?? 'development', }, providerHealthy() ? 200 : 503) diff --git a/apps/api/src/relay-nodes.ts b/apps/api/src/relay-nodes.ts index 1578e54..9e2682e 100644 --- a/apps/api/src/relay-nodes.ts +++ b/apps/api/src/relay-nodes.ts @@ -14,6 +14,11 @@ const DEFAULT_PROBE = { } as const const HEARTBEAT_MAX_AGE_SECONDS = 45 +type ConfiguredRelay = { + id: string + origin: string +} + function normalizeOrigin(value: string, name: string): string { let url: URL try { @@ -27,30 +32,60 @@ function normalizeOrigin(value: string, name: string): string { return url.origin } +function configuredSecondaryRelays( + value: string | undefined, + primaryNodeId: string, +): RelayNode[] { + if (!value?.trim()) return [] + let parsed: unknown + try { + parsed = JSON.parse(value) + } catch { + throw new Error('RELAY_NODES_JSON must be valid JSON') + } + if (!Array.isArray(parsed)) { + throw new Error('RELAY_NODES_JSON must be an array') + } + const seen = new Set([primaryNodeId]) + return parsed.map((entry, index) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new Error(`RELAY_NODES_JSON[${index}] must be an object`) + } + const relay = entry as Partial + const id = typeof relay.id === 'string' ? relay.id.trim() : '' + const origin = typeof relay.origin === 'string' ? relay.origin.trim() : '' + if (!id || !origin) { + throw new Error(`RELAY_NODES_JSON[${index}] must contain id and origin`) + } + if (seen.has(id)) { + throw new Error(`RELAY_NODES_JSON contains duplicate node id: ${id}`) + } + seen.add(id) + return { + id, + origin: normalizeOrigin(origin, `RELAY_NODES_JSON[${index}].origin`), + role: 'relay' as const, + acceptingNewSessions: true, + } + }) +} + export function configuredRelayNodes( env: NodeJS.ProcessEnv = process.env, ): RelayNode[] { + const primaryNodeId = primaryRelayNodeId(env) const primaryOrigin = normalizeOrigin( env.RELAY_PRIMARY_ORIGIN ?? env.PUBLIC_APP_URL ?? `http://localhost:${env.PORT ?? 8787}`, 'RELAY_PRIMARY_ORIGIN', ) - const nodes: RelayNode[] = [{ - id: primaryRelayNodeId(env), + return [{ + id: primaryNodeId, origin: primaryOrigin, role: 'primary', acceptingNewSessions: relayAcceptingNewSessions(env), - }] - if (env.RELAY_CN_ENABLED !== 'false' && env.RELAY_CN_ORIGIN) { - nodes.push({ - id: env.RELAY_CN_NODE_ID?.trim() || 'cn-relay', - origin: normalizeOrigin(env.RELAY_CN_ORIGIN, 'RELAY_CN_ORIGIN'), - role: 'relay', - acceptingNewSessions: true, - }) - } - return nodes + }, ...configuredSecondaryRelays(env.RELAY_NODES_JSON, primaryNodeId)] } export async function availableRelayNodeList( diff --git a/apps/api/src/server-role.ts b/apps/api/src/server-role.ts index 3443705..8a7e554 100644 --- a/apps/api/src/server-role.ts +++ b/apps/api/src/server-role.ts @@ -9,7 +9,7 @@ export function serverRole(env: NodeJS.ProcessEnv = process.env): ServerRole { export function relayNodeId(env: NodeJS.ProcessEnv = process.env): string { const value = env.RELAY_NODE_ID?.trim() if (value) return value - return serverRole(env) === 'primary' ? 'jp-primary' : 'cn-relay' + return serverRole(env) === 'primary' ? 'jp-primary' : 'relay-node' } export function primaryRelayNodeId(env: NodeJS.ProcessEnv = process.env): string { diff --git a/apps/api/test/relay-routes.test.ts b/apps/api/test/relay-routes.test.ts index 8c2353a..55551db 100644 --- a/apps/api/test/relay-routes.test.ts +++ b/apps/api/test/relay-routes.test.ts @@ -4,8 +4,7 @@ import { FREE_MONTHLY_SECONDS } from '../src/quota' const previous = { RELAY_PRIMARY_ORIGIN: process.env.RELAY_PRIMARY_ORIGIN, - RELAY_CN_ORIGIN: process.env.RELAY_CN_ORIGIN, - RELAY_CN_ENABLED: process.env.RELAY_CN_ENABLED, + RELAY_NODES_JSON: process.env.RELAY_NODES_JSON, STT_ACTIVE: process.env.STT_ACTIVE, LLM_ACTIVE: process.env.LLM_ACTIVE, LLM_OPENROUTER_MODEL: process.env.LLM_OPENROUTER_MODEL, @@ -19,10 +18,28 @@ afterEach(() => { }) describe('relay control plane', () => { + it('publishes only the primary node when the relay is disabled', async () => { + process.env.RELAY_PRIMARY_ORIGIN = 'https://app.kibotalk.app' + process.env.RELAY_NODES_JSON = '[]' + + const response = await app.request('/api/relay/nodes') + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ + nodes: [ + expect.objectContaining({ + id: 'jp-primary', + origin: 'https://app.kibotalk.app', + }), + ], + primaryNodeId: 'jp-primary', + }) + }) + it('publishes both configured nodes and signs a node-bound session grant', async () => { process.env.RELAY_PRIMARY_ORIGIN = 'https://app.kibotalk.app' - process.env.RELAY_CN_ORIGIN = 'http://123.99.200.156:8443' - process.env.RELAY_CN_ENABLED = 'true' + process.env.RELAY_NODES_JSON = JSON.stringify([ + { id: 'sg-relay', origin: 'https://sg-relay.kibotalk.app' }, + ]) process.env.STT_ACTIVE = 'dashscope-realtime' process.env.LLM_ACTIVE = 'openrouter' process.env.LLM_OPENROUTER_MODEL = 'deepseek-v4-flash' @@ -36,8 +53,8 @@ describe('relay control plane', () => { expect(nodes.nodes).toEqual([ expect.objectContaining({ id: 'jp-primary', origin: 'https://app.kibotalk.app' }), expect.objectContaining({ - id: 'cn-relay', - origin: 'http://123.99.200.156:8443', + id: 'sg-relay', + origin: 'https://sg-relay.kibotalk.app', }), ]) expect(nodes.probe).toMatchObject({ attempts: 5 }) @@ -47,7 +64,7 @@ describe('relay control plane', () => { headers: { 'content-type': 'application/json' }, body: JSON.stringify({ conversationSessionId: 'session-1', - nodeId: 'cn-relay', + nodeId: 'sg-relay', }), }) expect(grantResponse.status).toBe(200) @@ -64,7 +81,7 @@ describe('relay control plane', () => { } expect(grant.token.split('.')).toHaveLength(2) expect(grant.claims).toMatchObject({ - nodeId: 'cn-relay', + nodeId: 'sg-relay', conversationSessionId: 'session-1', quotaSeconds: 30 * 60, }) diff --git a/apps/api/test/relay-session-state.test.ts b/apps/api/test/relay-session-state.test.ts index 3711716..71e59b6 100644 --- a/apps/api/test/relay-session-state.test.ts +++ b/apps/api/test/relay-session-state.test.ts @@ -19,7 +19,7 @@ const claims: RelaySessionClaims = { userId: 'user-1', deviceSessionId: 'device-1', conversationSessionId: 'session-1', - nodeId: 'cn-relay', + nodeId: 'sg-relay', scopes: ['llm', 'stt-realtime'], sttProvider: 'dashscope-realtime', llmProvider: 'openai', diff --git a/apps/api/test/relay-token.test.ts b/apps/api/test/relay-token.test.ts index 0c60289..3f78916 100644 --- a/apps/api/test/relay-token.test.ts +++ b/apps/api/test/relay-token.test.ts @@ -22,7 +22,7 @@ const input = { userId: 'user-1', deviceSessionId: 'device-1', conversationSessionId: 'conversation-1', - nodeId: 'cn-relay', + nodeId: 'sg-relay', scopes: ['llm'] as const, sttProvider: 'dashscope-realtime', llmProvider: 'openai', @@ -37,7 +37,7 @@ describe('relay session token', () => { const issued = issueRelaySessionToken(input, { env, now }) const claims = verifyRelaySessionToken(issued.token, { env, - nodeId: 'cn-relay', + nodeId: 'sg-relay', requiredScope: 'llm', now, }) @@ -59,7 +59,7 @@ describe('relay session token', () => { })).toBeNull() expect(verifyRelaySessionToken(token, { env, - nodeId: 'cn-relay', + nodeId: 'sg-relay', requiredScope: 'stt-realtime', now, })).toBeNull() @@ -78,12 +78,12 @@ describe('relay session token', () => { expect(verifyRelaySessionToken(tampered, { env, - nodeId: 'cn-relay', + nodeId: 'sg-relay', now, })).toBeNull() expect(verifyRelaySessionToken(token, { env, - nodeId: 'cn-relay', + nodeId: 'sg-relay', now: new Date(now.getTime() + 31 * 60 * 1000), })).toBeNull() }) diff --git a/apps/playground/src/components/ConfigFields.tsx b/apps/playground/src/components/ConfigFields.tsx index 9da0a11..2bc4b5d 100644 --- a/apps/playground/src/components/ConfigFields.tsx +++ b/apps/playground/src/components/ConfigFields.tsx @@ -239,7 +239,7 @@ export function RelayNodeSelect({ disabled }: { disabled?: boolean }) { {results.map(({ node, latencyMs }) => { const kind = relayNodeLabelKind(node) const title = - kind === 'local' ? '本地节点' : kind === 'primary' ? '日本主节点' : '国内中转' + kind === 'local' ? '本地节点' : kind === 'primary' ? '日本主节点' : '中转节点' return ( {title} diff --git a/docs/adr/0006-session-pinned-api-relay.md b/docs/adr/0006-session-pinned-api-relay.md index 09841fa..67a609e 100644 --- a/docs/adr/0006-session-pinned-api-relay.md +++ b/docs/adr/0006-session-pinned-api-relay.md @@ -1,99 +1,51 @@ -# 会话固定的双节点 API 中转 +# 会话固定的可选 API 中转节点 -- **状态**:Accepted +- **状态**:Accepted(当前生产仅启用日本主节点) - **日期**:2026-07-26 -- **覆盖**:ADR 0005 的单 VPS 数据面、生产 realtime-only STT 和每月 10 分钟免费额度 +- **更新**:2026-07-27 ## 背景 -日本 VPS 继续承载 Web、登录鉴权、PostgreSQL、同步、额度账本和管理后台,但中国 -大陆用户访问日本 API 的实时语音与 LLM 流容易受跨境链路抖动影响。现有客户端编排 -边界不变;新增一台国内服务器,只承担 provider 数据面的中转。 - -最终只有两个节点: - -- `jp-primary`:日本主服务器,兼控制面与 API 中转; -- `cn-relay`:国内中转服务器,无数据库和用户长期凭据。 +日本主服务器承载 Web、登录鉴权、PostgreSQL、同步、额度账本和管理后台,同时也是默认 +数据节点。未来如果用户到日本的实时链路需要优化,可以在香港、新加坡等区域增加一个或 +多个 HTTPS 中转节点,但客户端编排和日本控制面的权威性不变。 ## 决策 -### 会话开始选路 - -日本主服务器动态下发可用节点。客户端在每次新会话开始前并行探测节点的 -`/api/latency`,并把结果展示给用户: - -- 每节点共 5 次请求; -- 第一次仅用于 DNS/TLS/连接预热,不计分; -- 其余成功样本取中位数,单次 1500 ms 超时; -- 延迟只包含用户到节点的 HTTP 往返,不包含节点到 provider 的延迟; -- 用户明确选择日本或国内节点;不可达节点不能选择; -- 国内 HTTP 节点在选择项中持续显示未加密风险,不由客户端自动选择。 - -用户可以在停止状态的设置页修改默认节点并查看最近一次实测延迟;该偏好只高亮下一 -次会话选择器的默认项,不跳过会话开始时的最新测速、安全提示和人工确认。 - -用户选择后,客户端先申请短令牌并完成所选节点握手,成功后才正式创建本地会话; -失败时不自动改用另一节点。确认后 `relayNodeId` 写入持久化会话并冻结。暂停、刷新 -或进程恢复只重连原节点,不重新测速;会话中节点故障只重试原节点,仍失败则结束或 -暂停会话。 - -### 控制面与短令牌 +### 通用节点发现 -日本服务器使用 Ed25519 私钥签发 30 分钟短令牌,两节点使用公钥离线验签。令牌绑定 -用户、设备、conversation session、选定节点、STT-realtime / LLM scope、`sttProvider`、 -model 和签发时可用额度。客户端在第 20 分钟开始静默续签;续签只能得到相同节点。 +日本主服务器始终发布 `jp-primary`,并通过 `RELAY_NODES_JSON` 配置零个或多个可选 +中转节点。生产当前设置为: -实时 STT 建连前,用短令牌向所选节点交换 60 秒、单次使用的 WebSocket ticket, -完整短令牌不进入 WebSocket URL。日本仍通过活跃会话租约保证同一账号只有一个 -AI 会话。 +```dotenv +RELAY_NODES_JSON=[] +``` -### 国内节点状态与计量 +配置项只包含稳定节点 ID 和 HTTPS origin。节点健康状态仍由心跳决定;未配置、不健康 +或停止接收新会话的节点不会下发给客户端。 -国内节点: - -- 直接连接 STT/LLM provider,密钥只存在服务端; -- 不连接日本 PostgreSQL; -- 不持久化音频、转写、prompt 或模型回复; -- 本地按已授权剩余额度计量,到零后完成当前 turn 和一次最终回复; -- 将用量事件写入本地持久化 outbox,再用独立节点凭据幂等回传日本; -- 主动上报 provider 健康、是否接收新会话和近期活动会话; -- 最多接受一个逻辑活动会话;桌面 `audioSource=both` 可使用两条物理 STT 流。 - -节点间控制请求不在音频或 LLM token 实时路径上。日本收到用量事件后继续使用原有 -PostgreSQL 账本、幂等 request ID 和 final allowance。 - -### 运维 - -两台服务器使用同一 API bundle,以 `SERVER_ROLE=primary|relay` 区分暴露面。国内 -节点使用专用精简镜像,只包含 Hono API,不包含 Web 静态文件、模型、数据库或反向 -代理。宿主机将公网 `8443` 直接映射到容器内 `8787`,日本控制面下发 -`http://123.99.200.156:8443`。 - -无域名且机房不开放 80/443 时,公有 CA 无法通过 HTTP-01 或 TLS-ALPN-01 为该 -公网 IP 自动签发证书,所以国内入口暂时为 HTTP。macOS 桌面端可以使用该节点; -HTTPS Web 页面会因浏览器 mixed-content 策略探测失败并回退日本节点。中转短令牌 -仍只有 30 分钟有效,但 HTTP 不提供链路机密性;开放 443 或恢复域名 TLS 后应升级 -为 HTTPS。 - -国内节点全量参与新会话选路,不做灰度。保留两个止损能力: +### 会话开始选路 -- 日本设置 `RELAY_CN_ENABLED=false`,立即停止向新会话下发国内节点; -- 国内设置 `RELAY_ACCEPT_NEW_SESSIONS=false` 进入排空,现有令牌可用至到期但不续签。 +客户端在每次新会话开始前并行探测当前下发节点的 `/api/latency`: -运行遥测保存节点 ID、探测延迟、成功次数和选择结果,不保存原始用户 IP 或对话内容。 -日本主服务器的 OTP 防滥用可以继续保存 `AUTH_SECRET` 加盐的不可逆 IP 哈希并按其限流; -该哈希不进入国内节点遥测或用量事件。 +- 每节点共 5 次请求,第一次预热不计分; +- 其余成功样本取中位数,单次 1500 ms 超时; +- 延迟仅表示用户到节点的 HTTP 往返; +- 用户明确选择节点,不可达节点不能选择; +- 节点选择写入会话并冻结,会话中不自动换节点。 -### 额度调整 +### 控制面与中转模块 -普通用户北京时间自然月免费额度从 10 分钟提高为 30 分钟。既有当月 10 分钟桶在 -首次读取时补足 grant 差额;节点选择与剩余额度无关,实际 AI 使用仍在额度归零时停止。 +日本服务器使用 Ed25519 私钥签发 30 分钟短令牌,各节点使用公钥离线验签。令牌绑定 +用户、设备、会话、节点、provider 和额度;第 20 分钟起只允许原节点续签。 -生产 STT 仅 realtime(`STT_ACTIVE=dashscope-realtime`);短令牌授权 -`stt-realtime` scope 与冻结的 `sttProvider`。 +通用中转节点不连接日本 PostgreSQL,不持久化音频、转写、prompt 或模型回复。它只 +承载 realtime STT / LLM 数据面,把幂等用量事件和健康心跳回传日本。服务器通过 +`SERVER_ROLE=primary|relay` 复用同一 API bundle,`infra/relay` 保留通用部署 +adapter,但不属于当前日本生产发布流程。 ## 后果 -用户到国内节点的实时链路可以绕开访问日本 API 的跨境抖动,同时日本继续作为唯一 -鉴权、账务与持久化权威。代价是两节点密钥轮换、短令牌密钥、outbox、心跳与双服务 -器发布成为新的生产运维面;在恢复可信 TLS 前,国内节点不具备链路机密性。 +当前只有日本节点,因此没有额外服务器、跨境镜像上传或未加密 HTTP 入口。未来新增 +香港或新加坡节点时,只需提供通用节点配置、HTTPS 入口和独立部署路径;客户端会自动 +显示新节点并沿用测速、人工选择和会话固定逻辑,不需要再加入地区专用分支。 diff --git a/docs/production-deployment.md b/docs/production-deployment.md index 4062dc1..19855b9 100644 --- a/docs/production-deployment.md +++ b/docs/production-deployment.md @@ -1,222 +1,76 @@ # Production deployment -This runbook covers the public landing page at `https://kibotalk.app`, the -Web product and primary API at `https://app.kibotalk.app`, and the data-plane -relay at `http://123.99.200.156:8443`. The previous competition hostname -`https://advx.kibotalk.app` clears obsolete origin-local model/voiceprint data -and then replaces browser navigation with the landing page. -It intentionally excludes payments, automated backups, alerting, notarization, -and automatic desktop updates. - -## Runtime layout - -- Japan primary: Ubuntu x86_64 at the host stored in `SERVER_HOST`. - `/opt/kibotalk/compose.yaml` runs Caddy, Hono, and PostgreSQL. Authentication, - account data, quota authority, relay discovery, token signing, Web UI, and a - complete fallback data plane stay here. -- China relay: Ubuntu x86_64 at the host stored in `CN_SERVER_HOST`. - `/opt/kibotalk-relay/compose.yaml` runs only the Hono API container. It has - no Web assets, model, reverse proxy, or product database. It accepts - session-scoped STT/LLM traffic, keeps a durable minimal usage outbox, and - reports it to Japan. -- Each host has a local `.env` with mode `0600`; CI never uploads or replaces - either secret file. -- Japan Caddy's named volumes retain its automatically issued TLS certificate. -- The Caddy image contains the static `apps/landing` build. `www.kibotalk.app` - redirects permanently to the apex domain. -- `advx.kibotalk.app` retains only a standalone origin-cleanup entry and legacy - API/model paths. This keeps already released desktop clients working while - normal browser navigation proceeds to the landing page. -- `/opt/kibotalk/models`: same revision-pinned Q8 files used only when a Web - client cannot load the primary Hugging Face copy. -- Desktop models are bundled into the DMG. The VPS does not store installers. - -The deploy workflow builds the Linux/amd64 primary API, a separate API-only -relay target, the Japan Caddy image, and PostgreSQL image on GitHub Actions. -It transfers one compressed archive to each host over SSH and starts Compose -with `--pull never`. Neither host needs reliable access to GitHub or Docker Hub -during a release. - -## One-time cryptographic material - -Generate one Ed25519 key pair and one independent node credential on an -offline/admin machine: +Production currently runs on one Japanese primary server: -```bash -openssl genpkey -algorithm Ed25519 -out relay-token-private.pem -openssl pkey -in relay-token-private.pem -pubout -out relay-token-public.pem -openssl rand -base64 48 -``` +- Web, API, authentication, PostgreSQL, quota, and sync; +- realtime STT through Alibaba Cloud Japan (Tokyo); +- LLM proxy; +- the `jp-primary` data-plane node. -Put the complete private PEM only in the Japan `.env` as -`RELAY_TOKEN_PRIVATE_KEY`. Put the public PEM on both hosts as -`RELAY_TOKEN_PUBLIC_KEY`. Put the same random 48-byte value on both hosts as -`RELAY_NODE_SECRET`. Shell multiline values are fragile; base64-encoding each -PEM into one line is also accepted: +Pushes to `main` and manual workflow dispatches run +`.github/workflows/deploy-production.yml`. The workflow validates the Tokyo +provider configuration, runs typecheck and tests, uploads the API and bundled +on-device models, activates the release, and verifies the public endpoints. -```bash -base64 < relay-token-private.pem | tr -d '\n' -base64 < relay-token-public.pem | tr -d '\n' -``` +## Primary environment -Delete local plaintext key files after placing and backing up the secrets. - -## Japan primary environment - -Create `/opt/kibotalk/.env` from `.env.example` and set at least: - -- `POSTGRES_PASSWORD` -- `AUTH_SECRET` -- `SYNC_ENCRYPTION_KEY` -- `STT_DASHSCOPE_API_KEY` (Japan (Tokyo) workspace key) -- `STT_DASHSCOPE_WS_URL=wss://{WorkspaceId}.ap-northeast-1.maas.aliyuncs.com/api-ws/v1/realtime` -- `LLM_OPENROUTER_API_KEY` -- `RESEND_API_KEY` -- `RESEND_FROM_EMAIL` -- `RESEND_FROM_NAME` -- `ADMIN_EMAILS` -- `PUBLIC_APP_URL=https://app.kibotalk.app` -- `SERVER_ROLE=primary` -- `RELAY_NODE_ID=jp-primary` -- `RELAY_PRIMARY_ORIGIN=https://app.kibotalk.app` -- `RELAY_CN_ORIGIN=http://123.99.200.156:8443` -- `RELAY_CN_NODE_ID=cn-relay` -- `RELAY_CN_ENABLED=true` -- `RELAY_TOKEN_PRIVATE_KEY` -- `RELAY_TOKEN_PUBLIC_KEY` -- `RELAY_NODE_SECRET` - -`RESEND_FROM_EMAIL` must use a sender domain verified in Resend. Keep -`ALLOW_DEV_OTP=false` in production. - -The China relay keeps its own China (Beijing) `STT_DASHSCOPE_API_KEY` and -`STT_DASHSCOPE_WS_URL`; production deployment only replaces the primary -server's STT values from the `JP_STT_DASHSCOPE_*` GitHub secrets. - -## China relay environment - -Create `/opt/kibotalk-relay/.env` with mode `0600`. Copy the same provider -configuration and provider keys used by Japan, then set: +The primary server keeps its environment at `/opt/kibotalk/.env`. Relevant +node settings are: ```dotenv -SERVER_ROLE=relay -RELAY_NODE_ID=cn-relay +SERVER_ROLE=primary +RELAY_NODE_ID=jp-primary RELAY_PRIMARY_ORIGIN=https://app.kibotalk.app -RELAY_OUTBOX_PATH=/app/data/usage-outbox.json -RELAY_ACCEPT_NEW_SESSIONS=true -RELAY_HTTP_PORT=8443 -RELAY_TOKEN_PUBLIC_KEY= -RELAY_NODE_SECRET= +RELAY_NODES_JSON=[] ``` -Do not set `DATABASE_URL`, `AUTH_SECRET`, `SYNC_ENCRYPTION_KEY`, Resend -credentials, or `RELAY_TOKEN_PRIVATE_KEY` on this host. -The relay does not persist raw IP addresses or IP hashes. The Japan primary -may retain only the existing keyed IP hash used for OTP abuse prevention; it -does not store the original IP value. +`RELAY_NODES_JSON=[]` is the authoritative Japan-only topology. The production +workflow writes this value on every release so a retired node cannot +accidentally return through stale server configuration. -## China port and HTTP +Tokyo realtime STT uses: -The relay does not use ports 80 or 443. Before first deployment, test 8443 on -the China host: - -```bash -sudo ss -lntup | grep -E ':(8443|9443)\b' || true -sudo ufw allow 8443/tcp +```dotenv +STT_ACTIVE=dashscope-realtime +STT_DASHSCOPE_WS_URL=wss://.ap-northeast-1.maas.aliyuncs.com/api-ws/v1/realtime +STT_DASHSCOPE_REALTIME_MODEL=qwen3-asr-flash-realtime ``` -From a machine outside the provider network, verify that a temporary listener -can be reached. If the provider blocks 8443, set `RELAY_HTTP_PORT=9443`, open -TCP 9443, and change both `RELAY_CN_ORIGIN` and the `CN_RELAY_ORIGIN` GitHub -secret to `http://123.99.200.156:9443`. - -The IP endpoint is intentionally plain HTTP because the host does not expose -the port 80 or 443 required to validate a public IP certificate. It is usable -by the macOS desktop app. A Web page served over HTTPS cannot use it because -browsers block active mixed content and will therefore fall back to Japan. +The API key and workspace URL are stored as GitHub Actions secrets +`JP_STT_DASHSCOPE_API_KEY` and `JP_STT_DASHSCOPE_WS_URL`. -For the Japan host, keep the Cloudflare apex, `www`, `app`, and `advx` records -DNS-only and point them at the Japanese VPS. Ports 80 and 443 must remain open. -Caddy obtains and renews those certificates automatically. +## Verification -## GitHub deployment credentials +The workflow verifies: -Set these repository Actions secrets: +```bash +curl --fail https://app.kibotalk.app/health +curl --fail https://app.kibotalk.app/models/onnx-community/wespeaker-voxceleb-resnet34-LM/config.json +curl --fail https://kibotalk.app/en/ +``` -- Japan: `DEPLOY_SSH_KEY`, `SERVER_HOST`, `SERVER_USER` -- China: `CN_DEPLOY_SSH_KEY`, `CN_SERVER_HOST`, `CN_SERVER_USER` -- Public relay origin including the port: `CN_RELAY_ORIGIN` +It also checks the model binary, the `www` redirect, and the legacy-origin +cleanup page before reporting success. -Pushes to `main` and manual dispatches deploy both nodes. A release succeeds -only after Japan health/model/landing checks and China health/latency checks -all pass. +## Optional relay nodes -## First-node verification +The runtime retains a generic relay module for a future HTTPS node in a region +such as Hong Kong or Singapore. A relay has no user database or long-lived user +credentials. It validates short-lived node-bound grants, proxies realtime STT +and LLM traffic, and reports usage to the primary. -After deployment, verify discovery and the data plane: +Configure one or more nodes on the primary with JSON: -```bash -curl --fail https://app.kibotalk.app/health -curl --fail http://123.99.200.156:8443/health -curl --fail http://123.99.200.156:8443/api/latency +```dotenv +RELAY_NODES_JSON=[{"id":"sg-relay","origin":"https://sg-relay.kibotalk.app"}] ``` -Sign in with the production macOS app and open the new-session node picker. It -must show measured user-to-node latency for Japan and China, label China as -unencrypted HTTP, and keep the user's selected node for the whole session. -The HTTPS Web app must show China as unreachable and continue to allow Japan. -Temporarily set `RELAY_CN_ENABLED=false` on Japan and restart only its API to -exercise the emergency Japan-only path. - -## Drain, rollback, and recovery - -- Drain China without breaking active sessions by setting - `RELAY_ACCEPT_NEW_SESSIONS=false` on China and restarting its API. Its - heartbeat removes it from new-session discovery; existing 30-minute tokens - remain valid. -- Immediately remove China from all new sessions by setting - `RELAY_CN_ENABLED=false` on Japan and restarting its API. -- Roll back application code by setting `KIBOTALK_API_IMAGE` on Japan and - `KIBOTALK_RELAY_IMAGE` on China to previously loaded SHA images, then run - `docker compose up -d --pull never` in each deployment directory. -- Never delete the relay data volume while usage events are pending. Confirm - `/app/data/usage-outbox.json` is `[]` before replacing or removing it. - -## Browser-origin cleanup - -Changing from `advx.kibotalk.app` to `app.kibotalk.app` changes the browser -origin. Cookies, localStorage, IndexedDB, service workers, and model caches do -not automatically follow the user. Product data does not need a browser-origin -transfer: accounts, quotas, preferences, and conversation history already live -in PostgreSQL through mandatory encrypted sync. Users sign in again on `app`; -voice enrollment can be recorded again. - -For normal browser paths on `advx`, Caddy serves the standalone -`legacy-origin-cleanup.html` entry. It deletes Cache Storage (including locally -cached models), unregisters service workers, clears current and known legacy -speaker-embedding stores, then uses `location.replace` to open -`https://kibotalk.app/`. It does not touch conversation IndexedDB, localStorage, -cookies, raw audio, or server data. - -Keep the `advx` DNS record and cleanup entry for the announced cleanup window. -After the window, remove the standalone cleanup module and replace its Caddy -fallback with a permanent redirect. Retain legacy API/model paths until every -supported desktop release uses `app.kibotalk.app`. - -## Releases - -- Push/PR runs Linux CI. -- Push to `main` or manual dispatch deploys production. -- A version tag or manual macOS workflow builds only Apple Silicon, runs the Q8 - speaker regression, creates an ad-hoc-signed DMG, and retains a workflow - artifact. A version tag also publishes the DMG and SHA-256 file to GitHub - Releases, which is the only public installer download location. -- No Apple Developer account, provisioning profile, or notarization is used. - Users must explicitly approve first launch in macOS privacy/security settings. - -## Shutdown - -Before the competition deployment is removed, create one PostgreSQL export and -copy it off the VPS. Then stop the stack and remove the competition DNS record. -User text is retained until account deletion or this deliberate shutdown -procedure; raw audio and speaker embeddings are never uploaded. +Each relay uses the generic `infra/relay/compose.yaml` adapter and must set its +own `RELAY_NODE_ID`, `RELAY_PRIMARY_ORIGIN`, provider keys, +`RELAY_TOKEN_PUBLIC_KEY`, and `RELAY_NODE_SECRET`. Adding a relay requires a +separate deployment path and HTTPS endpoint; the current production workflow +intentionally deploys only Japan. + +The client always obtains the node list from the primary, measures +user-to-node latency when a session begins, requires an explicit choice, and +pins that node for the entire session. diff --git a/docs/spec/live-reply-coach-mvp.md b/docs/spec/live-reply-coach-mvp.md index 1794be6..e1a407f 100644 --- a/docs/spec/live-reply-coach-mvp.md +++ b/docs/spec/live-reply-coach-mvp.md @@ -132,7 +132,7 @@ type LanguagePrefs = { launchAtLogin: boolean audioSource: 'microphone' | 'system' | 'both' microphoneDeviceId: string - relayNodeId: 'jp-primary' | 'cn-relay' // 下次会话节点选择器的默认项 + relayNodeId: string // 下次会话节点选择器的默认项 } /** Frozen when a session starts; drives UI history, audio, STT + LLM. */ diff --git a/infra/production/env.example b/infra/production/env.example index 8816947..f3549ce 100644 --- a/infra/production/env.example +++ b/infra/production/env.example @@ -22,3 +22,8 @@ LLM_OPENAI_MODEL=deepseek-v4-flash APP_VERSION=0.1.0 APP_DMG_URL=https://github.com/KiboAI/KiboTalk/releases APP_RELEASE_NOTES_URL=https://github.com/KiboAI/KiboTalk/releases + +SERVER_ROLE=primary +RELAY_NODE_ID=jp-primary +RELAY_PRIMARY_ORIGIN=https://app.kibotalk.app +RELAY_NODES_JSON=[] diff --git a/infra/relay/compose.yaml b/infra/relay/compose.yaml index 5580e8d..03ed8f5 100644 --- a/infra/relay/compose.yaml +++ b/infra/relay/compose.yaml @@ -10,7 +10,6 @@ services: PORT: 8787 APP_ENV: production SERVER_ROLE: relay - RELAY_NODE_ID: cn-relay RELAY_OUTBOX_PATH: /app/data/usage-outbox.json ports: - "${RELAY_HTTP_PORT:-8443}:8787" diff --git a/packages/app-shared/src/config.ts b/packages/app-shared/src/config.ts index 5fd1262..f45d6d6 100644 --- a/packages/app-shared/src/config.ts +++ b/packages/app-shared/src/config.ts @@ -49,7 +49,7 @@ export const defaultLanguagePrefs: { } export type ProductTheme = 'system' | 'light' | 'dark' -export type RelayNodePreference = 'jp-primary' | 'cn-relay' +export type RelayNodePreference = string export const defaultProductPrefs: { conversationLang: AppLanguage diff --git a/packages/app-shared/src/i18n.tsx b/packages/app-shared/src/i18n.tsx index 8a03177..482a308 100644 --- a/packages/app-shared/src/i18n.tsx +++ b/packages/app-shared/src/i18n.tsx @@ -137,7 +137,7 @@ const messages = { checkingLatency: '正在测量节点延迟…', localNode: '本地节点', japanNode: '日本主节点', - chinaNode: '国内中转节点', + relayNode: '中转节点', encryptedConnection: 'HTTPS 加密连接', insecureRelayWarning: 'HTTP 未加密:语音、提示词、回复和短令牌可能被链路中间方看到或篡改。选择即表示你了解此风险。', nodeUnreachable: '当前不可达', @@ -276,7 +276,7 @@ const messages = { checkingLatency: 'ノードの遅延を測定しています…', localNode: 'ローカルノード', japanNode: '日本メインノード', - chinaNode: '中国リレーノード', + relayNode: 'リレーノード', encryptedConnection: 'HTTPS 暗号化接続', insecureRelayWarning: 'HTTP は暗号化されません。音声、プロンプト、返信、短期トークンを経路上の第三者が閲覧または改ざんする可能性があります。選択すると、このリスクを理解したものとみなされます。', nodeUnreachable: '現在接続できません', @@ -415,7 +415,7 @@ const messages = { checkingLatency: 'Measuring node latency…', localNode: 'Local node', japanNode: 'Japan primary', - chinaNode: 'China relay', + relayNode: 'Relay node', encryptedConnection: 'Encrypted with HTTPS', insecureRelayWarning: 'HTTP is not encrypted. An intermediary may read or alter audio, prompts, replies, and the short-lived token. Selecting it confirms you understand this risk.', nodeUnreachable: 'Currently unreachable', diff --git a/packages/app-shared/src/language-prefs.ts b/packages/app-shared/src/language-prefs.ts index fd77236..9e92964 100644 --- a/packages/app-shared/src/language-prefs.ts +++ b/packages/app-shared/src/language-prefs.ts @@ -54,8 +54,8 @@ export function loadLanguagePrefs(): LanguagePrefs { audioSource: parsed.audioSource ?? fallback.audioSource, microphoneDeviceId: parsed.microphoneDeviceId ?? fallback.microphoneDeviceId, relayNodeId: - parsed.relayNodeId === 'cn-relay' - ? 'cn-relay' + typeof parsed.relayNodeId === 'string' && parsed.relayNodeId.trim() + ? parsed.relayNodeId : fallback.relayNodeId, } } catch { diff --git a/packages/app-shared/src/relay-routing.ts b/packages/app-shared/src/relay-routing.ts index fb52998..658228a 100644 --- a/packages/app-shared/src/relay-routing.ts +++ b/packages/app-shared/src/relay-routing.ts @@ -19,7 +19,7 @@ export function isLocalRelayOrigin(origin: string): boolean { } } -/** Display label key for a relay node: local vs primary vs china relay. */ +/** Display label key for a relay node: local vs primary vs secondary relay. */ export function relayNodeLabelKind( node: Pick, ): 'local' | 'primary' | 'relay' { diff --git a/packages/app-shared/test/api-runtime-relay.test.ts b/packages/app-shared/test/api-runtime-relay.test.ts index 0ba3b05..923ef8d 100644 --- a/packages/app-shared/test/api-runtime-relay.test.ts +++ b/packages/app-shared/test/api-runtime-relay.test.ts @@ -17,8 +17,8 @@ const primary: RelayNode = { acceptingNewSessions: true, } const relay: RelayNode = { - id: 'cn-relay', - origin: 'http://123.99.200.156:8443', + id: 'sg-relay', + origin: 'https://sg-relay.kibotalk.app', role: 'relay', acceptingNewSessions: true, } @@ -102,7 +102,7 @@ describe('relay API runtime', () => { const response = await relayFetch('/api/llm', { method: 'POST' }) expect(response.ok).toBe(true) expect(calls).toContainEqual({ - url: 'http://123.99.200.156:8443/api/llm', + url: 'https://sg-relay.kibotalk.app/api/llm', authorization: 'Bearer short-token', }) diff --git a/packages/app-shared/test/cloud-conversation-storage.test.ts b/packages/app-shared/test/cloud-conversation-storage.test.ts index fdc66cf..18e044c 100644 --- a/packages/app-shared/test/cloud-conversation-storage.test.ts +++ b/packages/app-shared/test/cloud-conversation-storage.test.ts @@ -146,7 +146,7 @@ describe('CloudConversationStorage recovery', () => { await expect(cloud.startSession({ id: 'new-session', - relayNodeId: 'cn-relay', + relayNodeId: 'sg-relay', startedAt: 10, snapshot: session('snapshot').snapshot, title: 'local first', diff --git a/packages/app-shared/test/language-prefs.test.ts b/packages/app-shared/test/language-prefs.test.ts index 1eac6d6..2c5da05 100644 --- a/packages/app-shared/test/language-prefs.test.ts +++ b/packages/app-shared/test/language-prefs.test.ts @@ -48,4 +48,15 @@ describe('loadLanguagePrefs', () => { languagesConfirmed: true, }) }) + + it('keeps a configured generic relay preference', () => { + values.set('kibotalk.languagePrefs', JSON.stringify({ + uiLang: 'zh', + conversationLang: 'ja', + level: 'beginner', + relayNodeId: 'relay-node', + })) + + expect(loadLanguagePrefs().relayNodeId).toBe('relay-node') + }) }) diff --git a/packages/app-shared/test/relay-routing.test.ts b/packages/app-shared/test/relay-routing.test.ts index b077a44..a1e0668 100644 --- a/packages/app-shared/test/relay-routing.test.ts +++ b/packages/app-shared/test/relay-routing.test.ts @@ -9,8 +9,8 @@ const primary: RelayNode = { acceptingNewSessions: true, } const relay: RelayNode = { - id: 'cn-relay', - origin: 'http://123.99.200.156:8443', + id: 'sg-relay', + origin: 'https://sg-relay.kibotalk.app', role: 'relay', acceptingNewSessions: true, } diff --git a/packages/pages/src/RelayNodeDialog.tsx b/packages/pages/src/RelayNodeDialog.tsx index 71cd578..c952970 100644 --- a/packages/pages/src/RelayNodeDialog.tsx +++ b/packages/pages/src/RelayNodeDialog.tsx @@ -14,7 +14,7 @@ import { CircleAlert, RefreshCw, ShieldCheck, Wifi } from 'lucide-react' function relayNodeTitle( node: Pick, - t: (key: 'localNode' | 'japanNode' | 'chinaNode') => string, + t: (key: 'localNode' | 'japanNode' | 'relayNode') => string, ): string { const kind = relayNodeLabelKind(node) switch (kind) { @@ -23,7 +23,7 @@ function relayNodeTitle( case 'primary': return t('japanNode') case 'relay': - return t('chinaNode') + return t('relayNode') default: { const _exhaustive: never = kind return _exhaustive diff --git a/packages/pages/src/SettingsPage.tsx b/packages/pages/src/SettingsPage.tsx index 03f9a3d..7dcb580 100644 --- a/packages/pages/src/SettingsPage.tsx +++ b/packages/pages/src/SettingsPage.tsx @@ -84,7 +84,6 @@ const LANGUAGES: AppLanguage[] = ['ja', 'en', 'zh'] const LEVELS: LearnerLevel[] = ['beginner', 'intermediate', 'advanced'] const THEMES: ProductTheme[] = ['system', 'light', 'dark'] const AUDIO_SOURCES: SessionAudioSource[] = ['microphone', 'system', 'both'] -const RELAY_NODES: RelayNodePreference[] = ['jp-primary', 'cn-relay'] function SettingRow({ title, @@ -158,6 +157,22 @@ export function SettingsPage({ .catch(() => setMicrophones([])) }, [microphonePermission, platform]) + useEffect(() => { + if (relayProbes.loading || relayProbes.results.length === 0) return + if (relayProbes.results.some(({ node }) => node.id === prefs.relayNodeId)) return + const primary = relayProbes.results.find(({ node }) => node.role === 'primary') + if (!primary) return + onPrefsChange({ + ...prefs, + relayNodeId: primary.node.id as RelayNodePreference, + }) + }, [ + onPrefsChange, + prefs, + relayProbes.loading, + relayProbes.results, + ]) + const sections = useMemo( () => [ @@ -393,31 +408,26 @@ export function SettingsPage({ - {RELAY_NODES.map((nodeId) => { - const result = relayProbes.results.find( - ({ node }) => node.id === nodeId, - ) + {relayProbes.results.map((result) => { + const nodeId = result.node.id as RelayNodePreference let latencyLabel = t('checkingLatency') - if (result?.latencyMs === null) { + if (result.latencyMs === null) { latencyLabel = t('nodeUnreachable') - } else if (result) { + } else { latencyLabel = `${Math.round(result.latencyMs)} ms` } - let title = nodeId === 'jp-primary' ? t('japanNode') : t('chinaNode') - if (result?.node) { - const kind = relayNodeLabelKind(result.node) - title = - kind === 'local' - ? t('localNode') - : kind === 'primary' - ? t('japanNode') - : t('chinaNode') - } + const kind = relayNodeLabelKind(result.node) + const title = + kind === 'local' + ? t('localNode') + : kind === 'primary' + ? t('japanNode') + : t('relayNode') return ( {title} {' · '} @@ -430,7 +440,11 @@ export function SettingsPage({ - {prefs.relayNodeId === 'cn-relay' ? ( + {relayProbes.results.some( + ({ node }) => + node.id === prefs.relayNodeId + && node.origin.startsWith('http:'), + ) ? (
{t('insecureRelayWarning')} diff --git a/packages/pipeline/test/conversation-session-storage.test.ts b/packages/pipeline/test/conversation-session-storage.test.ts index 40dc52f..bcba9a8 100644 --- a/packages/pipeline/test/conversation-session-storage.test.ts +++ b/packages/pipeline/test/conversation-session-storage.test.ts @@ -60,7 +60,7 @@ describe('conversation session lifecycle storage', () => { await storage.stopActiveSession(2000) await storage.startSession({ id: 'active', - relayNodeId: 'cn-relay', + relayNodeId: 'sg-relay', startedAt: 3000, snapshot, title: 'active',