Commit Graph

148 Commits

Author SHA1 Message Date
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 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 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
hailin db4e70e30c fix: use EXTERNAL turn detection for Speechmatics in LiveKit pipeline
ADAPTIVE mode enables a second client-side Silero VAD inside the
Speechmatics SDK that conflicts with LiveKit's own VAD pipeline,
causing no transcription to be returned. EXTERNAL mode delegates
turn detection to LiveKit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 01:31:33 -08:00
hailin 9daf0e3b4f fix: bypass LanguageCode normalization that maps cmn back to zh
LiveKit's LanguageCode class normalizes ISO 639-3 codes to ISO 639-1
(cmn → zh), but Speechmatics API expects "cmn" not "zh". Override
the internal _stt_options.language after construction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 01:04:20 -08:00
hailin 7292ac6ca6 fix: use cmn instead of cmn_en for Speechmatics Voice Agent API
cmn_en bilingual code not supported by Voice Agent API, causes timeout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:19:50 -08:00
hailin 17ff9d3ce0 fix: use Speechmatics cmn_en bilingual model for Chinese-English mixed speech
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:57:26 -08:00
hailin 1d43943110 fix: correct Speechmatics STT language mapping and parameter name
- Map Whisper language codes (zh→cmn, en→en, etc.) to Speechmatics codes
- Fix parameter name: enable_partials → include_partials

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 23:56:37 -08:00
hailin e32a3a9800 fix: use @TenantId() decorator in VoiceConfigController for JWT tenant extraction
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 22:30:37 -08:00
hailin f9c47de04b feat: add STT provider switching (OpenAI ↔ Speechmatics) in settings
- Add VoiceConfig entity/repo/service/controller in agent-service
  for per-tenant STT provider persistence (default: speechmatics)
- Add Speechmatics STT plugin in voice-agent with livekit-plugins-speechmatics
- Modify voice-agent entrypoint for 3-way STT selection:
  metadata > agent-service config > env var fallback
- Add "Voice" section in web-admin settings page with STT provider dropdown
- Add i18n translations (en/zh) for voice settings
- Add SPEECHMATICS_API_KEY env var in docker-compose

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 22:13:18 -08:00
hailin 94e3153e39 chore: remove debug data_received logging 2026-03-02 06:50:41 -08:00
hailin 81e36bf859 debug: add data_received event logging to diagnose data channel 2026-03-02 06:38:02 -08:00
hailin 63b986fced fix: redesign voice call mixed-mode input with dual-layout architecture
Problem:
- Text input area caused BOTTOM OVERFLOWED BY 135 PIXELS when keyboard opened
- Input bar overlapped with call control buttons
- Sent messages were not displayed on screen (only SnackBar feedback)

Solution — split into two distinct layouts:

1. Call Mode (default):
   - Full-screen call UI: avatar, waveform, duration, large control buttons
   - Keyboard button in controls toggles to chat mode
   - No text input elements — clean voice-only interface

2. Chat Mode (tap keyboard button):
   - Compact call header: green status dot + "iAgent" + duration + inline
     mute/end/speaker/collapse controls
   - Scrollable message list (Expanded widget — properly handles keyboard)
   - User messages: right-aligned blue bubbles with attachment thumbnails
   - Agent responses: left-aligned gray bubbles with robot avatar
   - Input bar at bottom: attachment picker + text field + send button

Message display:
- User-sent text/attachments tracked in _messages list, shown as bubbles
- Agent responses sent back via LiveKit data channel (topic='text_reply')
  from voice-agent → Flutter, displayed as assistant bubbles
- Auto-scroll to latest message

Voice-agent change (agent.py):
- After session.say(response), publish response text back to Flutter via
  ctx.room.local_participant.publish_data() with topic='text_reply'
- Flutter listens for DataReceivedEvent to display agent responses

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 06:11:07 -08:00
hailin ce63ece340 feat: add mixed-mode input (text + images + files) during voice calls
Enable users to send text messages, images, and files to the Agent
while an active voice call is in progress. This addresses the case
where spoken instructions are unclear or screenshots/documents need
to be shared for analysis.

## Architecture

Data flows through LiveKit data channel (not direct HTTP):
  Flutter → publishData(topic='text_inject') → voice-agent
  → llm.inject_text_message() → POST /api/v1/agent/tasks (same session)
  → collect streamed response → session.say() → TTS playback

This preserves the constraint that voice-agent owns the agent-service
sessionId — Flutter never contacts agent-service directly.

## Flutter UI (agent_call_page.dart)
- Add keyboard toggle button to active call controls (4-button row)
- Collapsible text input area with attachment picker (+) and send button
- Attachment support: gallery multi-select, camera, file picker
  (images max 1024x1024 quality 80%, PDF supported, max 5 attachments)
- Horizontal scrolling attachment preview with delete buttons
- 200KB payload size check before LiveKit data channel send
- Layout adapts: Spacer flex 1/3 toggle, reduced bottom padding

## voice-agent (agent.py)
- Register data_received event listener after session.start()
- Filter for topic='text_inject', parse JSON payload
- Call llm.inject_text_message(text, attachments) and TTS via session.say()
- Use asyncio.ensure_future() wrapper for async handler (matches
  existing disconnect handler pattern for sync EventEmitter)

## AgentServiceLLM (agent_llm.py)
- New inject_text_message(text, attachments) method on AgentServiceLLM
- Reuses same _agent_session_id for conversation context continuity
- WS+HTTP streaming: connect, pre-subscribe, POST /tasks with
  attachments field, collect full text response, return string
- _injecting flag prevents concurrent _do_stream from clearing
  session ID on abort errors while inject is in progress
- Same systemPrompt/voiceMode/engineType as voice pipeline

No agent-service changes required — attachments already supported
end-to-end (JSONB storage → multimodal content blocks → Claude).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 05:38:04 -08:00
hailin 02aaf40bb2 fix: move voice instructions to systemPrompt, keep prompt clean
Previously, voice mode wrapped every user message with 【语音对话模式】
instructions, polluting conversation_messages history with repeated
instructions on every turn. Now:

- systemPrompt carries voice-mode instructions (set once, not per-message)
- prompt contains only the clean user text (identical to text chat pattern)
- Conversation history stays clean for multi-turn context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 03:24:50 -08:00
hailin da17488389 feat: voice mode event filtering — skip tool/thinking events for Agent SDK
1. Remove on_enter greeting entirely (no more race condition)
2. voice-agent sends voiceMode: true when engine_type is claude_agent_sdk
3. AgentController.runTaskStream() filters thinking, tool_use, tool_result
   events in voice mode — only text, completed, error reach the client
4. Detailed logging: each event logged with [FILTERED-voice] tag when skipped

Claude API mode is completely unaffected (voiceMode defaults to false).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 02:56:41 -08:00
hailin 7c9fabd891 fix: avoid Agent SDK race on greeting + clear session on abort
1. Change on_enter greeting from generate_reply() to session.say() with
   a static message — avoids spawning an Agent SDK task just for a greeting,
   which caused a race condition when the user speaks before it completes.

2. Clear agent session ID when receiving abort/exit errors so the next
   task starts a fresh session instead of trying to resume a dead process.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 02:22:52 -08:00
hailin a78e2cd923 chore: add detailed engine type logging for verification
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 02:18:29 -08:00
hailin 59a3e60b82 feat: add engine type selection (Agent SDK / Claude API) for voice calls
Full-stack implementation allowing users to choose between Claude Agent SDK
(default, with tool approval, skill injection, session resume) and Claude API
(direct, lower latency) in Flutter settings. Agent SDK mode wraps prompts with
voice-conversation instructions for concise spoken Chinese output.

Data flow: Flutter Settings → SharedPreferences → POST /livekit/token →
RoomAgentDispatch metadata → voice-agent → AgentServiceLLM(engine_type)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 02:11:51 -08:00
hailin e66c187353 fix: improve voice pipeline robustness for poor network conditions
Flutter (agent_call_page.dart):
- Add ConnectOptions with 15s timeouts for connection/peerConnection/iceRestart
- Add RoomReconnectingEvent/RoomAttemptReconnectEvent/RoomReconnectedEvent
  listeners with "网络重连中" UI indicator during reconnection
- Add TimeoutException detection in _friendlyError()

voice-agent (agent.py):
- Wrap entrypoint() in try-except with full traceback logging
- Register room disconnect listener to close httpx clients (instead of
  finally block, since session.start() returns while session runs in bg)
- Add asyncio import for ensure_future cleanup

voice-agent LLM proxy (agent_llm.py):
- Add retry with exponential backoff (max 2 retries, 1s/3s delays) for
  network errors (ConnectError/ConnectTimeout/OSError) and WS InvalidStatusCode
- Extract _do_stream() method for single-attempt logic
- Add WebSocket connection params: open_timeout=10, ping_interval=20,
  ping_timeout=10 for keepalive and faster dead-connection detection
- Use granular httpx.Timeout(connect=10, read=30, write=10, pool=10)
- Increase WS recv timeout from 5s to 30s to reduce unnecessary loops

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:34:55 -08:00
hailin 32922c6819 fix: adjust TTS default instructions for faster speech tempo
Changed from "语速适中" to "语速稍快,简洁干练" to reduce perceived
latency in voice conversations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:09:32 -08:00
hailin 186234bae2 fix: increase STT silence_duration_ms to prevent choppy transcription
Default silence_duration_ms=350 is too aggressive for Chinese speech,
causing sentences to be fragmented into 1-3 character chunks. Increase
to 800ms and raise VAD threshold to 0.6 so the STT waits longer before
finalizing a turn, producing complete sentences for LLM processing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 18:37:13 -08:00
hailin a5c95b460a fix: patch aiohttp SSL verification for OpenAI Realtime STT WebSocket
The OpenAI Realtime STT uses aiohttp WebSocket connections (not httpx),
so the existing httpx verify=False fix does not apply. LiveKit's
http_context creates aiohttp.TCPConnector without ssl=False, causing
SSL certificate verification errors when OPENAI_BASE_URL points to a
proxy with a self-signed certificate.

Monkey-patch http_context._new_session_ctx to inject ssl=False into the
aiohttp connector, fixing the "CERTIFICATE_VERIFY_FAILED" error for
Realtime STT WebSocket connections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 18:29:59 -08:00
hailin 5460be8c04 feat: add TTS voice and style settings to Flutter app
Add user-configurable TTS voice and tone style settings that flow from
the Flutter app through the backend to the voice-agent at call time.

## Flutter App (it0_app)

### Domain Layer
- app_settings.dart: Add `ttsVoice` (default: 'coral') and `ttsStyle`
  (default: '') fields to AppSettings entity with copyWith support

### Data Layer
- settings_datasource.dart: Add SharedPreferences keys
  `settings_tts_voice` and `settings_tts_style` for local persistence
  in loadSettings(), saveSettings(), and clearSettings()

### Presentation Layer
- settings_providers.dart: Add `setTtsVoice()` and `setTtsStyle()`
  methods to SettingsNotifier for Riverpod state management
- settings_page.dart: Add "语音" settings group between Notifications
  and Security groups with:
  - Voice picker: 13 OpenAI voices with gender/style labels
    (e.g. "女 · 温暖", "男 · 沉稳", "中性") in a BottomSheet
  - Style picker: 5 presets (专业干练/温柔耐心/轻松活泼/严肃正式/科幻AI)
    as ChoiceChips + custom text input field + reset button

### Call Flow
- agent_call_page.dart: Send `tts_voice` and `tts_style` in the POST
  body when requesting a LiveKit token at call initiation

## Backend

### voice-service (Python/FastAPI)
- livekit_token.py: Accept optional `tts_voice` and `tts_style` via
  Pydantic TokenRequest body model; embed them in RoomAgentDispatch
  metadata JSON alongside auth_header (backward compatible)

### voice-agent (Python/LiveKit Agents)
- agent.py: Extract `tts_voice` and `tts_style` from ctx.job.metadata;
  use them when creating openai_plugin.TTS() — user-selected voice
  overrides config default, user-selected style overrides default
  instructions. Falls back to config defaults when not provided.

## Data Flow
Flutter Settings → SharedPreferences → POST /livekit/token body →
voice-service embeds in RoomAgentDispatch metadata →
voice-agent reads from ctx.job.metadata → TTS creation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:38:15 -08:00
hailin 705647d732 feat: upgrade TTS to gpt-4o-mini-tts with voice instructions
- Switch from tts-1 to gpt-4o-mini-tts for lower latency and better quality
- Change voice from alloy to coral
- Add Chinese speech instructions for natural tone control

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 08:19:05 -08:00
hailin ba83e433d3 feat: enable OpenAI Realtime STT for streaming speech recognition
Switch from batch STT (gpt-4o-transcribe via /audio/transcriptions)
to streaming Realtime API (WebSocket). This eliminates the ~2s batch
upload+process latency per utterance.

Also updated nginx proxy on 67.223.119.33 to support WebSocket upgrade
for /v1/realtime endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:49:25 -08:00
hailin e302891f16 fix: disable SSL verify for self-signed OpenAI proxy + handle no-user-msg
- Pass httpx.AsyncClient(verify=False) to OpenAI STT/TTS to support
  self-signed certificate on OPENAI_BASE_URL proxy
- Handle generate_reply calls with no user message by falling back to
  system/developer instructions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:39:49 -08:00
hailin 4d47c6a955 fix: remove wait_for_participant — room not connected in rtc_session mode
In livekit-agents v1.x @server.rtc_session() pattern, ctx.room is not
yet connected when entrypoint is called. session.start() handles room
connection internally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:15:37 -08:00
hailin 2112445191 fix: voice-agent crash — add room I/O options and filter AgentConfigUpdate
- Add room_input_options/room_output_options to session.start() so agent
  binds audio I/O and stays in the room
- Add wait_for_participant() before starting session
- Filter AgentConfigUpdate items in agent_llm.py (no 'role' attribute)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 21:08:07 -08:00
hailin 00be878a95 fix: refactor voice-agent to official LiveKit v1.x AgentServer pattern
Replace deprecated WorkerOptions(entrypoint_fnc=...) with AgentServer() +
@server.rtc_session() decorator. Use server.setup_fnc for prewarm. Remove
manual ctx.connect() and ctx.wait_for_participant() calls that prevented
the pipeline from properly wiring up VAD→STT→LLM→TTS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:31:31 -08:00
hailin 75b14d5200 fix: use RoomOptions instead of deprecated RoomInputOptions
RoomInputOptions is deprecated in livekit-agents 1.4.x. Switch to
RoomOptions with explicit audio_input/audio_output enabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:32:36 -08:00