Commit Graph

281 Commits

Author SHA1 Message Date
hailin 4c7c05eb37 feat(stt): support auto language detection for mixed Chinese-English input
- Flutter: language='auto' omits the language field → backend receives none
- Backend: no language field → passes undefined to STT service
- STT service: language=undefined → omits language param from Whisper request
- Whisper auto-detects language per utterance when no hint is provided

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 08:13:26 -08:00
hailin 23675fa5a5 feat(chat): use app language setting for voice-to-text STT language
Reads settingsProvider.language (BCP-47 code) and passes it to the
Whisper transcribe call instead of hardcoding 'zh'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 08:11:21 -08:00
hailin 0aac693b5d fix(app): re-check connectivity on foreground resume to clear false-offline banner
When backgrounded, the periodic TCP ping times out causing isOnline=false.
On resume, immediately re-check so the banner clears as soon as the app
is foregrounded rather than waiting up to 30s for the next scheduled check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 08:08:01 -08:00
hailin 947a47869e fix(agent-service): use https.request for Whisper STT to bypass self-signed cert
Node 18 native fetch (undici) ignores https.Agent, causing fetch failed
on the self-signed proxy at 67.223.119.33:8443. Switch to https.request
with rejectUnauthorized: false which works reliably.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 07:51:37 -08:00
hailin 72584182df fix(chat): fix VoiceMicButton gesture conflict with IconButton tooltip
GestureDetector was fighting with IconButton's inner Tooltip gesture
recognizer — onLongPressStart was never called (only vibration from
tooltip). Replaced with Listener (raw pointer events) + manual 500ms
Timer, which bypasses the gesture arena entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 07:47:48 -08:00
hailin 73eb4350fb fix(agent-service): strip /v1 suffix from OPENAI_BASE_URL in STT service
OPENAI_BASE_URL=https://67.223.119.33:8443/v1 already includes /v1,
so the URL was being built as .../v1/v1/audio/transcriptions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 07:27:16 -08:00
hailin 15ee296fcd fix(agent-service): add multer as explicit runtime dependency
multer was only transitively available; pnpm strict mode blocks it.
Also adds @types/multer for TypeScript compilation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 07:10:22 -08:00
hailin 07783ccad2 fix(agent-service): add @types/multer to devDependencies
Fixes TS2307 build error: Cannot find module 'multer' or its type declarations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 07:03:54 -08:00
hailin 2182149c4c feat(chat): voice-to-text fills input box instead of auto-sending
- Add POST /api/v1/agent/transcribe endpoint (STT only, no agent trigger)
- Add transcribeAudio() to chat datasource and provider
- VoiceMicButton now fills the text input field with transcript;
  user reviews and sends manually
- Add OPENAI_API_KEY/OPENAI_BASE_URL to agent-service in docker-compose

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 07:01:39 -08:00
hailin 5721d75461 feat(it0_app): add PTT mode to agent call page
- Default to PTT (push-to-talk) on call connect: mic muted until user holds button
- Toggle switch between PTT and free voice mode in active call controls
- PTT button: press-and-hold unmutes mic, release mutes again
- Voice message bubble (waveform + duration) appears after each PTT send
- Mute button hidden in PTT mode (mic controlled by PTT button)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 06:49:53 -08:00
hailin e6f864d409 fix(version-service+gateway+app): fix APK download 404 and SHA-256 false failure
Three coordinated fixes to make in-app APK download work end-to-end:

1. version-service/main.ts: serve uploaded files as static assets via
   NestExpressApplication.useStaticAssets('/data/versions', prefix:
   '/downloads/versions'), so GET /downloads/versions/{platform}/{file}
   returns the actual APK stored in the Docker volume.

2. kong.yml: add /downloads/versions route to Kong so requests from
   the Flutter app can reach version-service through the API gateway.
   Previously only /api/v1/versions and /api/app/version were routed;
   the download URL returned by the check endpoint was unreachable (404).

3. download_manager.dart: skip SHA-256 verification when sha256Expected
   is empty string. The check endpoint always returns sha256:"" because
   version-service doesn't store file hashes. The previous code compared
   actual_hash == "" which always failed, causing the downloaded file to
   be deleted after a successful download.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 06:04:27 -08:00
hailin 0f328b9794 feat(it0_app): add detailed logging to VersionChecker for update diagnosis
Add verbose debugPrint logs throughout VersionChecker to diagnose why
app update check isn't triggering:
- Log apiBaseUrl and full request URL + query params before the request
- Log response status code and raw response body
- Log explicit needUpdate=true/false with version details
- Log version code comparison (server versionCode vs local buildNumber)
- Add stack trace to all catch blocks for better error diagnosis

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 05:51:45 -08:00
hailin 369ecb2f29 feat(gateway): add Kong route for app version check endpoint
Add /api/app/version route to Kong declarative config so that the
Flutter app's GET /api/app/version/check?platform=&current_version_code=
request can reach version-service through the API gateway.

Previously only /api/v1/versions was routed; the public check endpoint
served by AppVersionCheckController was unreachable (Kong returned
"no Route matched with those values").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 05:33:56 -08:00
hailin 141c6e984d feat(version-service): add GET /api/app/version/check endpoint for Flutter app
Flutter VersionChecker was calling GET /api/app/version/check but this
endpoint didn't exist — only the admin CRUD /api/v1/versions was there.

New: AppVersionCheckController (@Controller('api/app/version'))
  GET /api/app/version/check?platform=android&current_version_code=N
  - Finds latest enabled version for the platform (highest buildNumber)
  - Returns { needUpdate: false } when already up to date
  - Returns full VersionInfo payload when update is available

Response fields match Flutter VersionInfo.fromJson exactly:
  needUpdate, version, versionCode, downloadUrl, fileSize,
  fileSizeFriendly (computed), sha256 (empty — not stored),
  forceUpdate, updateLog, releaseDate

Also: AppVersionRepository.findLatestEnabled(platform) — queries all
enabled versions for platform, picks the one with the highest buildNumber
(parsed as int, robust against varchar storage).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 05:24:04 -08:00
hailin 95d678ad6b fix(web-admin): fix TypeScript cast error in normalize() for AppVersion
Cast via unknown first to satisfy strict TS type checker:
  ...(raw as unknown as AppVersion)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 04:54:46 -08:00
hailin b63341a464 feat(web-admin): add App Version Management page for IT0 App
Ports the APK/IPA upgrade management UI from rwadurian/mobile-upgrade
into it0-web-admin, adapted exclusively for IT0 App's version-service.

New files:
- src/domain/entities/app-version.ts
  Domain entity matching version-service response schema:
  platform returned as ANDROID/IOS (normalized to lowercase),
  fileSize as number (bigint), no versionCode/fileSha256 fields.

- src/infrastructure/repositories/api-app-version.repository.ts
  CRUD via existing apiClient (→ /api/proxy/api/v1/versions).
  Upload/parse use dedicated Next.js routes (/api/app-versions/*)
  because the existing proxy uses request.text() which corrupts binary.

- src/app/api/app-versions/upload/route.ts
  Multipart FormData upload proxy → API_BASE_URL/api/v1/versions/upload
  maxDuration=300s for large APK files (up to 500 MB).

- src/app/api/app-versions/parse/route.ts
  Multipart proxy → API_BASE_URL/api/v1/versions/parse
  Forwards APK/IPA file to version-service for auto-parsing.

- src/app/(admin)/app-versions/page.tsx
  Admin page: react-query list, platform filter (all/android/ios),
  upload button, loading skeleton, delete/toggle with confirm.
  Single-app (IT0 only) — no multi-app switcher from mobile-upgrade.

- src/presentation/components/app-versions/version-card.tsx
  Version card with enable/disable/edit/delete/download actions.
  Uses dark-theme CSS variables (bg-card, text-muted-foreground, etc.)

- src/presentation/components/app-versions/upload-modal.tsx
  Upload modal: auto-detects platform from .apk/.ipa extension,
  auto-parses version info via /parse endpoint, sonner toasts.

- src/presentation/components/app-versions/edit-modal.tsx
  Edit modal: update changelog, force-update flag, enabled state,
  min OS version. Loads version data on open via getVersionById.

Modified:
- sidebar.tsx: added Smartphone icon + appVersions nav item → /app-versions
- locales/zh/sidebar.json: "appVersions": "App 版本管理"
- locales/en/sidebar.json: "appVersions": "App Versions"

Backend: IT0 version-service at /api/v1/versions (no auth guard required)
Flutter: it0_app/lib/core/updater/version_checker.dart calls
  GET /api/app/version/check (public) for client-side update check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 04:51:19 -08:00
hailin 55b983a950 feat(it0_app): add WhatsApp-style voice message with async agent interrupt
New VoiceMicButton widget (press-and-hold to record, release to send):
- Records audio to a temp .m4a file via the `record` package
- Slide-up gesture cancels recording without sending
- Pulsing red mic icon + "松开发送/松开取消" feedback during recording

New flow for voice messages:
  1. Temp "🎤 识别中..." bubble shown immediately
  2. Audio uploaded to POST /api/v1/agent/sessions/:id/voice-message
     (multipart/form-data; backend runs Whisper STT)
  3. Placeholder replaced with real transcript
  4. WS stream subscribed via new subscribeExistingTask() to receive
     agent's streaming response — same pipeline as text chat

Voice messages act as async interrupts: if the agent is mid-task the
backend hard-cancels it before processing the new voice command,
so whoever presses the mic button always takes priority.

Files changed:
  chat_remote_datasource.dart — sendVoiceMessage() multipart upload
  chat_repository.dart        — subscribeExistingTask() interface method
  chat_repository_impl.dart   — implement subscribeExistingTask(); fix
                                sendVoiceMessage() stub
  chat_providers.dart         — ChatNotifier.sendVoiceMessage()
  voice_mic_button.dart       — NEW press-and-hold recording widget
  chat_page.dart              — mic button added to input area

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 03:20:41 -08:00
hailin a2af76bcd7 feat(agent-service): add voice message endpoint with Whisper STT and async interrupt
New endpoint: POST /api/v1/agent/sessions/:sessionId/voice-message
- Accepts multipart/form-data audio file (any format Whisper supports)
- Transcribes via OpenAI Whisper API (routed through existing proxy)
- If a task is currently running in the session → hard-interrupts it first
  (same cancel+inject pattern as text inject, triggered by voice command)
- Otherwise → starts a fresh task with the transcript
- Returns { sessionId, taskId, transcript } so client can subscribe to WS stream

This enables WhatsApp-style push-to-talk and doubles as an async voice
interrupt into any active agent workflow, bypassing the need for speaker
diarization (whoever presses record owns the message).

New files:
  infrastructure/stt/openai-stt.service.ts — OpenAI Whisper client,
  manually builds multipart/form-data, supports self-signed proxy cert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 03:12:03 -08:00
hailin d097c64c81 feat(voice): add per-turn interrupt support to VoiceSessionManager
Implements a two-level abort controller design to support real-time
interruption when the user speaks while the agent is still responding:

  sessionAbortController (session-scoped)
    - Created once when startSession() is called
    - Fired only by terminateSession() (user hangs up)
    - Propagated into each turn via addEventListener

  turnAbort (per-turn, stored as handle.currentTurnAbort)
    - Created fresh at the start of each executeTurn() call
    - Stored on the VoiceSessionHandle so injectMessage() can abort it
    - When a new inject arrives while a turn is running, injectMessage()
      calls turnAbort.abort() BEFORE enqueuing the new message

Interruption flow:
  1. User speaks mid-response → LiveKit stops TTS playback (client-side)
  2. STT utterance → POST voice/inject → injectMessage() fires
  3. handle.currentTurnAbort.abort() called → sets aborted flag
  4. for-await loop checks turnAbort.signal.aborted on next SDK event → break
  5. catch block NOT reached (break ≠ exception) → no error event emitted
  6. finally block saves partial text with "[中断]" suffix to history
  7. New message dequeued → fresh executeTurn() starts immediately

Why no "Agent error" message plays to the user:
  - break exits the for-await loop silently, not via exception
  - The catch block's error-event emission is guarded by err?.name !== 'AbortError'
    AND requires an actual exception; a plain break never enters catch
  - Empty or partial responses are filtered by `if response:` in agent.py

Also update module-level JSDoc with full architecture explanation covering
the long-lived run loop design, two-level abort hierarchy, tenant context
injection pattern, and SDK session resume across turns.

Update agent.py module docstring to document voice session lifecycle and
interruption flow for future maintainers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 04:25:57 -08:00
hailin 635cca18fa feat(voice): long-lived agent session with proper hangup termination
Replace the per-turn POST /tasks approach for voice calls with a
long-lived agent run loop tied to the call lifecycle:

agent-service:
- Add AsyncQueue<T> utility for blocking message relay
- Add VoiceSessionManager: spawns one background run loop per voice call,
  accepts injected messages, terminates cleanly on hangup
- Add VoiceSessionController with 3 endpoints:
    POST   /api/v1/agent/sessions/voice/start  (call start)
    POST   /api/v1/agent/sessions/:id/voice/inject  (each speech turn)
    DELETE /api/v1/agent/sessions/:id/voice    (user hung up)
- Register VoiceSessionManager + VoiceSessionController in agent.module.ts

voice-agent:
- AgentServiceLLM: add start_voice_session(), terminate_voice_session(),
  inject_text_message() (voice/inject-aware), _do_inject_voice()
- AgentServiceLLMStream._run(): use voice/inject path when voice session
  is active; fall back to per-task POST for text-chat / non-SDK engines
- entrypoint(): call start_voice_session() after session.start();
  register _on_room_disconnect that calls terminate_voice_session()
  so the agent is always killed when the user hangs up

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 04:01:02 -08:00
hailin 6ca8aab243 fix(agent-service): store proper title in session metadata, exclude systemPrompt from list API
Two issues fixed:

1. agent.controller.ts — on the FIRST task of each session, write title+voiceMode
   into session.metadata so the client can display a meaningful conversation title:
     - Text sessions: metadata.title = first 40 chars of user prompt
     - Voice sessions: metadata.title = '' + metadata.voiceMode = true
       (Flutter renders these as '语音对话 M/D HH:mm')
   titleSet flag prevents overwriting the title on subsequent turns of the same session.

2. session.controller.ts — listSessions() now returns a DTO instead of the raw entity.
   systemPrompt is an internal engine instruction and is explicitly excluded from the
   response. The client receives { id, status, engineType, metadata, createdAt, updatedAt }.
2026-03-04 02:39:47 -08:00
hailin 9546dab93d fix(it0_app): stop using systemPrompt as conversation title
Voice sessions set systemPrompt to the voice-mode instruction string,
causing every voice conversation to display '你正在通过语音与用户实时对话。请…'
as its title in the chat history list.

Title derivation priority (highest to lowest):
  1. metadata.title  — explicit title saved by backend on first task
  2. metadata.voiceMode == true → '语音对话 M/D HH:mm'
  3. Fallback → '对话 M/D HH:mm' based on session createdAt
2026-03-04 02:32:08 -08:00
hailin f0634c2e49 fix(billing-service): remove stale invoice.items reference after OneToMany removal 2026-03-04 01:49:23 -08:00
hailin df3b1a6ec6 fix(billing-service): fix entity table names (billing_ prefix) and column mappings to match migration 2026-03-04 01:47:56 -08:00
hailin d96ea91815 fix(ops-service): add new TenantInfo quota fields to inline TenantContextService.run calls 2026-03-04 00:04:36 -08:00
hailin ffe06fab7a fix(billing-service): add tsconfig with workspace path aliases
The billing-service tsconfig.json was missing the TypeScript path aliases
required for the workspace build (turbo builds shared packages first, then
resolves @it0/* via paths). Without these, nest build fails with
'Cannot find module @it0/database'.

Also disables overly strict checks (strictNullChecks, strictPropertyInitialization,
useUnknownInCatchVariables) to match the lenient settings used by other services.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 23:32:34 -08:00
hailin 40ee84a0b7 fix(billing-service): resolve all TypeScript compilation errors
Comprehensive fix of 124 TS errors across the billing-service:

Entity fixes:
- invoice.entity.ts: add InvoiceStatus/InvoiceCurrency const objects,
  rename fields to match DB schema (subtotalCents, taxCents, totalCents,
  amountDueCents), add OneToMany items relation
- invoice-item.entity.ts: add InvoiceItemType const object, add column
  name mappings and currency field
- payment.entity.ts: add PaymentStatus const, rename amount→amountCents
  with column name mapping, add paidAt field
- subscription.entity.ts: add SubscriptionStatus const object
- usage-aggregate.entity.ts: rename periodYear/Month→year/month to match
  DB columns, add periodStart/periodEnd fields
- payment-method.entity.ts: add displayName, expiresAt, updatedAt fields

Port/Provider fixes:
- payment-provider.port.ts: make PaymentProviderType a const object (not
  just a type), add PaymentSessionRequest alias, rename WebhookEvent with
  correct field shape (type vs eventType), make providerPaymentId optional
- All 4 providers: replace PaymentSessionRequest→CreatePaymentParams,
  fix amountCents→amount, remove sessionId from PaymentSession return,
  add confirmPayment() stub, fix Stripe API version to '2023-10-16'

Use case fixes:
- aggregate-usage.use-case.ts: replace 'redis' with 'ioredis' (workspace
  standard); rewrite using ioredis xreadgroup API
- change/check/generate use cases: fix Plan field names
  (monthlyPriceCentsUsd, includedTokens, overageRateCentsPerMTokenUsd)
- generate-monthly-invoice: fix SubscriptionStatus/InvoiceCurrency as
  values (now const objects)
- handle-payment-webhook: fix WebhookResult import, result.type usage,
  payment.paidAt

Controller/Repository fixes:
- plan.controller.ts, plan.repository.ts: fix Plan field names
- webhook.controller.ts: remove express import, use any for req type
- invoice-generator.service.ts: fix overageAmountCents→overageCentsUsd,
  monthlyPriceCny→monthlyPriceFenCny, includedTokensPerMonth→includedTokens

Dependencies:
- billing-service/package.json: replace redis with ioredis dependency
- pnpm-lock.yaml: regenerated after ioredis addition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 23:00:27 -08:00
hailin c7f3807148 fix(billing-service): add to Dockerfile.service and update pnpm lockfile
- Dockerfile.service: add COPY lines for billing-service/package.json in
  both build and production stages so pnpm install includes its deps
  (omission caused 'node_modules missing' turbo build error)
- pnpm-lock.yaml: regenerated after running pnpm install to include all
  billing-service dependencies (stripe, alipay-sdk, wechat-pay-v3, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 21:27:35 -08:00
hailin a58417d092 fix: correct billing migration schema refs and testing mock TenantInfo
- 005-create-billing-tables.sql: replace all `it0_shared.tenants` with
  `public.tenants` and all `tenant_id VARCHAR(20)` with `tenant_id UUID`
  to match the actual server DB schema (public schema, UUID primary key)
- packages/shared/testing src/test-utils.ts: add new quota fields
  (maxServers, maxUsers, maxStandingOrders, maxAgentTokensPerMonth) to
  TEST_TENANT mock to satisfy the extended TenantInfo interface, fixing
  the @it0/testing TypeScript build error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 21:22:02 -08:00
hailin 9ed80cd0bc feat: implement complete commercial monetization loop (Phases 1-4)
## Phase 1 - Token Metering + Quota Enforcement

### Usage Tracking
- agent-service: add UsageRecord entity (per-tenant schema) tracking
  inputTokens/outputTokens/costUsd per AI task
- Modify all 3 AI engines (claude-api, claude-code-cli, claude-agent-sdk)
  to emit separate input/output token counts in the `completed` event
- claude-api-engine: costUsd = (input*3 + output*15) / 1,000,000
  (claude-sonnet-4-5 pricing: $3/MTok in, $15/MTok out)
- agent.controller: persist UsageRecord and publish `usage.recorded`
  event to Redis Streams on every task completion (non-blocking)
- shared/events: new events UsageRecordedEvent, SubscriptionChangedEvent,
  QuotaExceededEvent, PaymentReceivedEvent

### Quota Enforcement
- TenantInfo: add maxServers, maxUsers, maxStandingOrders,
  maxAgentTokensPerMonth fields
- TenantContextMiddleware: rewritten to query public.tenants table for
  real quota values; 5-min in-memory cache; plan-based fallback on error
- TenantContextService: getTenant() returns null instead of throwing;
  added getTenantOrThrow() for strict callers
- inventory-service/server.controller: 429 when maxServers exceeded
- ops-service/standing-order.controller: 429 when maxStandingOrders exceeded
- auth-service/auth.service: 429 when maxUsers exceeded
- 002-create-tenant-schema-template.sql: add usage_records table

## Phase 2 - billing-service (New Microservice, port 3010)

### Domain Layer (public schema, all UUIDs)
Entities: Plan, Subscription, Invoice, InvoiceItem, Payment, PaymentMethod,
UsageAggregate

Domain services:
- SubscriptionLifecycleService: full state machine (trialing -> active ->
  past_due -> cancelled/expired); upgrades immediate, downgrades at period end
- InvoiceGeneratorService: monthly invoice = base fee + overage charges;
  proration item for mid-cycle upgrades
- OverageCalculatorService: (totalTokens - includedTokens) * overageRate

### Infrastructure (all repos use DataSource directly, NOT TenantAwareRepository)
- PlanRepository, SubscriptionRepository, InvoiceRepository (atomic
  transaction for invoice+items), PaymentRepository (payments + methods),
  UsageAggregateRepository (UPSERT via ON CONFLICT for atomic accumulation)

### Application Use Cases
- CreateSubscriptionUseCase: called on tenant registration
- ChangePlanUseCase: upgrade (immediate + proration) or downgrade (scheduled)
- CancelSubscriptionUseCase: immediate or at-period-end
- GenerateMonthlyInvoiceUseCase: cron target (1st of month 00:05 UTC);
  generates invoices, renews periods, applies scheduled downgrades
- AggregateUsageUseCase: Redis Streams consumer group billing-service,
  upserts monthly usage aggregates from usage.recorded events
- CheckTokenQuotaUseCase: hard limit enforcement per plan
- CreatePaymentSessionUseCase + HandlePaymentWebhookUseCase

### REST API
- GET  /api/v1/billing/plans
- GET/POST /api/v1/billing/subscription (+ /upgrade, /cancel)
- GET  /api/v1/billing/invoices (paginated)
- GET  /api/v1/billing/invoices/:id
- POST /api/v1/billing/invoices/:id/pay
- GET  /api/v1/billing/usage/current + /history
- CRUD /api/v1/billing/payment-methods
- POST /api/v1/billing/webhooks/{stripe,alipay,wechat,crypto}

### Plan Seed (auto on startup via PlanSeedService)
- free:       $0/mo,    100K tokens,  no overage,  hard limit 100%
- pro:        $49.99/mo, 1M tokens,  $8/MTok,  hard limit 150%
- enterprise: $199.99/mo, 10M tokens, $5/MTok, no hard limit

## Phase 3 - Payment Provider Integration

### PaymentProviderRegistry (Strategy Pattern, mirrors EngineRegistry)
All providers use @Optional() injection; unconfigured providers omitted

- StripeProvider: PaymentIntent API; webhook via stripe.webhooks.constructEvent
- AlipayProvider: alipay-sdk; Native QR (precreate); RSA2 signature verify
- WeChatPayProvider: v3 REST; Native Pay code_url; AES-256-GCM decrypt;
  HMAC-SHA256 request signing and webhook verification
- CryptoProvider: Coinbase Commerce; hosted checkout; HMAC-SHA256 verify

### WebhookController
All 4 webhook endpoints are public (no JWT) for payment provider callbacks.
rawBody: true enabled in main.ts for signature verification.

## Infrastructure Changes
- docker-compose.yml: billing-service container (port 13010);
  added as dependency of api-gateway
- kong.yml: /api/v1/billing routes (JWT); /api/v1/billing/webhooks (public)
- 005-create-billing-tables.sql: 7 billing tables + invoice sequence +
  ALTER tenants to add quota columns
- run-migrations.ts: 005 runs as part of shared schema step

## Phase 4 - Frontend

### Web Admin (Next.js)
New pages:
- /billing: subscription card + token usage bar + warning banner + invoices
- /billing/plans: comparison grid with USD/CNY toggle + upgrade/downgrade flow
- /billing/invoices: paginated table with Pay Now button
Sidebar: Billing group (CreditCard icon, 3 sub-items)
i18n: billing keys added to en + zh sidebar translations

### Flutter App
New feature module it0_app/lib/features/billing/:
- BillingOverviewPage: plan card + token LinearProgressIndicator +
  latest invoice + upgrade button
- BillingProvider (FutureProvider): parallel fetch subscription/quota/invoice
Settings page: "订阅与用量" entry card
Router: /settings/billing sub-route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 21:09:17 -08:00
hailin 54e3d442ed feat(it0_app): add auto-increment versionCode on each build
参照 rwadurian mobile-app 的版本自增机制,为 IT0 App 添加相同的逻辑:

- 使用 version.properties 文件持久化存储 VERSION_CODE 计数器
- 每次 gradle build 自动读取当前值 +1 并写回文件
- versionCode 使用自增数字(跨日期持续递增,确保每次构建唯一)
- versionName 格式: ${pubspec版本}.${自增号}(如 1.0.0.42)
- version.properties 已加入 .gitignore,每个构建环境独立维护计数器

这样每次编译 APK 都会自动获得一个比上次更高的版本号,
无需手动修改 pubspec.yaml 中的 version 字段。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 08:52:04 -08:00
hailin d5df46c2d6 fix: add /data/versions directory creation in Dockerfile
Ensure /data/versions/android and /data/versions/ios directories are
created with correct appuser ownership during image build, fixing
EACCES permission error when version-service starts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 08:27:53 -08:00
hailin 260195db50 fix(version-service): use DatabaseModule.forRoot() for correct build path
The entrypoint.sh expects dist/services/${SERVICE_NAME}/src/main, but
nest build with inline TypeORM config produces dist/main directly.
Using DatabaseModule from @it0/database forces tsc to emit the nested
path structure (since it references shared packages), matching the
entrypoint path convention used by all other services.

Also gains SnakeNamingStrategy and autoLoadEntities from the shared module.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 08:04:12 -08:00
hailin f6dffe02c5 feat: add version-service for IT0 App version management
New NestJS microservice (port 3009) providing complete version management
API for IT0 App, designed to integrate with the existing mobile-upgrade
frontend (update.szaiai.com).

Backend — packages/services/version-service/ (9 new files):
- AppVersion entity: platform (ANDROID/IOS), versionName, buildNumber,
  changelog, downloadUrl, fileSize, isForceUpdate, isEnabled, minOsVersion
- REST controller with 8 endpoints:
  GET/POST /api/v1/versions — list (with platform/disabled filters) & create
  GET/PUT/DELETE /api/v1/versions/:id — single CRUD
  PATCH /api/v1/versions/:id/toggle — enable/disable
  POST /api/v1/versions/upload — multipart APK/IPA upload (500MB limit)
  POST /api/v1/versions/parse — extract version info from APK/IPA
- File storage: /data/versions/{platform}/ via Docker volume
- APK/IPA parsing: app-info-parser package
- Database: public.app_versions table (non-tenant, platform-level)
- No JWT auth (internal version management, consistent with existing apps)

Infrastructure changes:
- Dockerfile.service: added version-service package.json COPY lines
- docker-compose.yml: version-service container (13009:3009), version_data
  volume, api-gateway depends_on
- kong.yml: version-service route (/api/v1/versions), CORS origin for
  update.szaiai.com (mobile-upgrade frontend domain)

Deployment note: nginx needs /downloads/versions/ location + client_max_body_size 500m

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 07:48:31 -08:00
hailin 26369be760 docs: add detailed comments for thinking state indicator mechanism
voice-agent agent.py:
- Module docstring explains lk.agent.state lifecycle
  (initializing → listening → thinking → speaking)
- Explains how RoomIO publishes state as participant attribute
- Documents BackgroundAudioPlayer with all available built-in clips

Flutter agent_call_page.dart:
- Documents _agentState field and all possible values
- Documents ParticipantAttributesChanged listener with UI mapping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:13:54 -08:00
hailin f1d9210e1d fix: correct BackgroundAudioPlayer import path
Import from livekit.agents.voice.background_audio submodule directly,
as it's not re-exported from livekit.agents.voice.__init__.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 06:03:34 -08:00
hailin 33bd1aa3aa feat: add "thinking" state indicator for voice calls
- voice-agent: enable BackgroundAudioPlayer with keyboard typing sound
  during LLM thinking state (auto-plays when agent enters "thinking",
  stops when "speaking" starts)
- Flutter: monitor lk.agent.state participant attribute from LiveKit
  agent, show pulsing dots animation + "思考中..." text when thinking,
  avatar border changes to warning color with pulsing glow ring
- Both call mode and chat mode headers show thinking state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 05:45:04 -08:00
hailin 121ca5a5aa docs: add Speechmatics STT postmortem — all 4 modes failed, unusable
Detailed record of why livekit-plugins-speechmatics was removed:
- EXTERNAL: no FINAL_TRANSCRIPT (framework never sends FlushSentinel)
- ADAPTIVE: zero output (dual Silero VAD conflict)
- SMART_TURN: fragments Chinese speech into tiny pieces
- FIXED: finalize() async race condition with session teardown
All tested on 2026-03-03, none viable with LiveKit agents v1.4.4.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 05:03:30 -08:00
hailin 7fb0d1de95 refactor: remove Speechmatics STT integration entirely, default to OpenAI
- Delete speechmatics_stt.py plugin
- Remove speechmatics branch from voice-agent entrypoint
- Remove livekit-plugins-speechmatics dependency
- Change default stt_provider to 'openai' in entity, controller, and UI
- Remove SPEECHMATICS_API_KEY from docker-compose.yml
- Remove speechmatics option from web-admin settings dropdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 04:58:38 -08:00
hailin 191ce2d6b3 fix: use FIXED mode with 1s silence trigger instead of SMART_TURN
SMART_TURN fragments continuous speech into tiny pieces, each triggering
an LLM request that aborts the previous one. FIXED mode waits for a
configurable silence duration (1.0s) before emitting FINAL_TRANSCRIPT
via the built-in END_OF_UTTERANCE handler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 04:53:00 -08:00
hailin e8a3e07116 docs: add comprehensive Speechmatics STT integration notes
Document all findings from the integration process directly in the
source code for future reference:

1. Language code mapping: Speechmatics uses ISO 639-3 "cmn" for
   Mandarin, but LiveKit LanguageCode auto-normalizes it to "zh".
   Must override stt._stt_options.language after construction.

2. Turn detection modes (critical):
   - EXTERNAL: unusable — LiveKit never sends FlushSentinel, only
     pushes silence frames, so FINAL_TRANSCRIPT never arrives
   - ADAPTIVE: unusable — client-side Silero VAD conflicts with
     LiveKit's own VAD, produces zero transcription output
   - SMART_TURN: correct choice — server-side intelligent turn
     detection, auto-emits FINAL_TRANSCRIPT, fully compatible

3. Speaker diarization: is_active flag distinguishes primary speaker
   from TTS echo, solving the "speaker confusion" problem

4. Docker deployment: SPEECHMATICS_API_KEY in .env, watch for
   COPY layer cache when rebuilding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 04:47:33 -08:00
hailin f30aa414dd fix: use SMART_TURN mode per Speechmatics official recommendation
Replace EXTERNAL mode + monkey-patch hack with SMART_TURN mode.
SMART_TURN uses Speechmatics server-side turn detection that properly
emits AddSegment (FINAL_TRANSCRIPT) when the user finishes speaking.
No client-side finalize or debounce timer needed.

Ref: https://docs.speechmatics.com/integrations-and-sdks/livekit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 04:44:21 -08:00
hailin de99990c4d fix: text-based dedup to prevent duplicate FINAL_TRANSCRIPT emissions
Speechmatics re-sends identical partial segments during silence, causing
the debounce timer to fire multiple times with the same text. Each
duplicate FINAL aborts the in-flight LLM request and restarts it.

Replace time-based cooldown with text comparison: skip finalization if
the segment text matches the last finalized text. Also skip starting
new timers when partial text hasn't changed from last finalized.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 04:40:00 -08:00
hailin 3b0119fe09 fix: reduce STT latency, add cooldown dedup, enable diarization
- Reduce debounce delay from 700ms to 400ms for faster response
- Add 1.5s cooldown after emitting FINAL to prevent duplicate triggers
  that cause LLM abort/retry cycles
- Enable speaker diarization (enable_diarization=True)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 03:20:12 -08:00
hailin 8ac1884ab4 fix: use debounce timer to auto-finalize Speechmatics partial transcripts
The LiveKit framework never sends FlushSentinel to the STT stream.
Instead it pushes silence frames and waits for FINAL_TRANSCRIPT events.
In EXTERNAL turn-detection mode, Speechmatics only emits partials.

New approach: each partial transcript restarts a 700ms debounce timer.
When partials stop (user stops speaking), the timer fires and promotes
the last partial to FINAL_TRANSCRIPT, unblocking the pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 03:08:17 -08:00
hailin de3eccafd0 debug: add verbose logging to Speechmatics monkey-patch
Trace _patched_process_audio lifecycle and FlushSentinel handling
to diagnose why final transcripts are not being promoted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:50:04 -08:00
hailin 1431dc0c83 fix: directly promote partial transcripts to FINAL on FlushSentinel
VoiceAgentClient.finalize() schedules an async task chain that often
loses the race against session teardown. Instead, intercept partial
segments as they arrive, stash them, and synchronously emit them as
FINAL_TRANSCRIPT when FlushSentinel fires.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:16:46 -08:00
hailin 73fd56f30a fix: durable monkey-patch for Speechmatics finalize on flush
Move the SpeechStream._process_audio patch from container runtime
into our own source code so it survives Docker rebuilds. The patch
adds client.finalize() on FlushSentinel so EXTERNAL mode produces
final transcripts when LiveKit's VAD detects end of speech.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:00:42 -08:00
hailin 6707c5048d fix: use EXTERNAL mode + patch plugin to finalize on flush
EXTERNAL mode produces partial transcripts but livekit-plugins-speechmatics
does not call finalize() when receiving a flush sentinel from the framework.
A runtime monkey-patch on the plugin's SpeechStream._process_audio adds the
missing finalize() call so final transcripts are generated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 01:58:25 -08:00
hailin 8f951ad31c fix: use turn_detection=stt for Speechmatics per official docs
Speechmatics handles end-of-utterance natively via its Voice Agent
API (ADAPTIVE mode). Use turn_detection="stt" on AgentSession so
LiveKit delegates turn boundaries to the STT engine instead of
conflicting with its own VAD-based turn detection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 01:44:10 -08:00