Commit Graph

356 Commits

Author SHA1 Message Date
hailin 6e50f4cc50 feat(web-admin): show copyable invite link after sending invite
After a platform admin sends an invite, the generated invite URL is
displayed inline with a one-click copy button so it can be shared via
any channel (email, WeChat, etc.). Link auto-dismisses when the invite
form is reopened.

Also adds i18n keys for invite link UI in en/zh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 05:37:30 -08:00
hailin e31baa1f40 fix(auth): fix invite flow UUID/slug mismatch and removeMember cleanup
- TenantController invite endpoints (list/create/revoke) were passing the
  tenant UUID from the URL param directly to AuthService methods that
  expect a slug, causing 404 on every invite operation. Now resolves
  tenant via findTenantOrFail() first and passes slug.
- removeMember now also deletes from public.users so removed members
  can no longer log in.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 05:34:47 -08:00
hailin 915bd400c1 fix(auth): insert invited users into public.users on acceptInvite
Previously, acceptInvite only wrote to the tenant schema, causing invited
users to be invisible to the login() flow which queries public.users for
cross-tenant email/phone lookup. Now inserts into both public.users and
the tenant schema within the same transaction, matching registerWithNewTenant behavior.

Also tightens duplicate check to cross-tenant uniqueness (public.users)
instead of per-tenant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 05:31:50 -08:00
hailin 2813c6a1bf feat(tenants): make tenant name a link to detail page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 05:04:19 -08:00
hailin 0bc81bbe40 feat(tenants): add delete button to tenant list page
Each tenant row now has a Delete button with confirmation dialog.
Previously delete was only accessible from the detail page which
had no navigation link from the list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 04:54:51 -08:00
hailin 6459e5b500 fix(tenants): allow platform_admin to delete tenants; fix invite/user cleanup
- DELETE /api/v1/admin/tenants/:id now accepts platform_admin role
- Fix cascade cleanup to use tenant slug (not UUID) for users/invites/api_keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 04:44:42 -08:00
hailin 129c5cbeab fix(auth): use slug lookup for tenant in validateInvite and acceptInvite
invite.tenantId stores the slug (not UUID), so findOneBy must use { slug }

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 04:26:21 -08:00
hailin 100ca43460 fix(auth): use slug for tenant lookup in createInvite; fix getMemberCount search_path
- createInvite: findOneBy({ slug }) instead of { id } since JWT tenantId is slug
- getMemberCount: use SET LOCAL + transaction to prevent pool search_path leak

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 04:17:14 -08:00
hailin 074e031685 fix(api-test): use correct tokens and accept 404 for billing subscription
- Users list: use ADMIN_TOKEN (platform_admin role required)
- Billing subscription: accept 200 or 404 (new tenants have no subscription)
- Invite flow: use TOKEN (tenant admin 'admin' role) not ADMIN_TOKEN

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 04:15:06 -08:00
hailin a24eb84e13 fix(provisioning): prevent search_path pool contamination via SET LOCAL + RESET
- Change SET search_path to SET LOCAL in tenant schema template (002)
  so it reverts on COMMIT and doesn't contaminate the connection pool
- Add RESET search_path before queryRunner.release() as defensive measure
- Add ALTER TABLE public.tenants admin_email DROP NOT NULL to migration 007
  to sync the direct server change back to source

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 04:04:53 -08:00
hailin 76389a337e fix(auth): properly map raw SQL snake_case rows to User entity camelCase fields 2026-03-07 03:54:34 -08:00
hailin 00357d855d fix(auth): use explicit public. schema for all login/register queries to prevent search_path contamination 2026-03-07 03:50:05 -08:00
hailin c2ba432341 fix(auth): dual-write tenant admin to public.users for login + tenant schema for management 2026-03-07 03:42:27 -08:00
hailin 7a6752bf74 fix(auth): use SET LOCAL search_path to prevent connection pool contamination; fix api-test routing 2026-03-07 03:35:49 -08:00
hailin 09d9200235 fix(auth): make tenant.adminEmail nullable for phone-only registrations; fix api-test status parsing 2026-03-07 03:27:23 -08:00
hailin 938802da96 fix(scripts): fix bash arithmetic exit code in api-test.sh 2026-03-07 03:24:44 -08:00
hailin b8128b7a07 fix(auth): make JwtPayload email optional, add phone to JWT payload 2026-03-07 03:21:32 -08:00
hailin 96bf5e7390 feat(auth): add phone registration support + enterprise register page redesign
- User entity: email nullable, add phone field (nullable unique)
- AuthService/Controller: login/register accept email OR phone
- UserRepository: findByPhone(), findByIdentifier() (auto-detects email vs phone)
- Migration 007: ALTER public.users + all existing tenant schemas to add phone
- Tenant schema template (002): users table now includes phone column
- Register page: enterprise-focused design, email/phone toggle, app download section
- Auth i18n (zh/en): new keys for phone, enterprise messaging, download CTA

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 03:14:47 -08:00
hailin 67691fc24d fix(users): handle {data, total} response shape from listUsers API
The backend returns { data: User[], total: number } but the frontend
was treating usersData directly as User[], causing filteredUsers.map
to throw 'not a function' when the page loaded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 02:46:04 -08:00
hailin 60cf49432e feat(billing): add CNY overage rate field and auto-detect currency from locale
- Add overage_rate_fen_per_m_token_cny column (migration 006)
- Plan entity and seed updated with CNY overage rates (Pro ¥58, Enterprise ¥36)
- upsertSeedPlans now updates existing plans (not insert-only)
- Plan controller exposes overageRateCnyPerMToken
- Frontend: currency auto-selects from i18n locale (zh→CNY, en→USD)
- Frontend: Intl.NumberFormat for proper currency formatting
- Currency toggle redesigned as pill selector

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 02:18:25 -08:00
hailin af1cae9da8 feat(i18n): add billing namespace, fully internationalize billing pages
billing/page.tsx, billing/plans/page.tsx, billing/invoices/page.tsx
were hardcoded in English. Added zh/billing.json and en/billing.json
covering overview, plans, and invoices sections. Registered billing
namespace in i18n config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 01:46:05 -08:00
hailin c1fb39c3c0 fix(topbar): hide tenant indicator for platform admin users
Platform admins operate across all tenants and don't belong to any
specific tenant — showing 'Tenant: Not selected' was misleading.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 01:36:39 -08:00
hailin 816c5461f9 feat(auth): add platform_super_admin role for two-level platform access control
在 platform_admin 之上新增 platform_super_admin 角色,实现平台管理员的两级权限体系。

## 角色层级

  platform_super_admin > platform_admin > admin > operator > viewer

- platform_super_admin:最高平台权限,含所有 platform_admin 操作 + 破坏性操作(删除租户/用户/版本)
- platform_admin:日常平台运营,可查看/编辑租户、管理 App 版本、配置账单套餐,不可执行删除

## 变更明细

### auth-service — role-type.vo.ts
- 新增 RoleType.PLATFORM_SUPER_ADMIN = 'platform_super_admin'

### auth-service — tenant.controller.ts
- 租户列表/创建/查看/编辑:@Roles('platform_admin', 'platform_super_admin')(两级均可)
- 删除租户 DELETE /:id:@Roles('platform_super_admin')(仅超管)

### auth-service — user.controller.ts
- 类级别:@Roles('platform_admin', 'platform_super_admin')(两级均可访问用户列表/创建/编辑)
- 删除用户 DELETE /:id:@Roles('platform_super_admin')(仅超管)

### version-service — guards/platform-admin.guard.ts
- 更新:接受 platform_admin 或 platform_super_admin 任一角色
- 重构:抽取 decodeJwtRoles() 工具函数,供 PlatformSuperAdminGuard 复用

### version-service — guards/platform-super-admin.guard.ts(新文件)
- 仅接受 platform_super_admin 角色
- 与 PlatformAdminGuard(类级别)叠加使用,实现方法级别的超管限制

### version-service — version.controller.ts
- DELETE /:id:叠加 @UseGuards(PlatformSuperAdminGuard)(仅超管可删除版本文件)

### web-admin — sidebar.tsx
- isPlatformAdmin 检测同时涵盖 platform_admin 和 platform_super_admin
- 两级平台管理员均显示相同侧边栏菜单

## 升级现有账号为 platform_super_admin
  UPDATE public.users SET roles = '{platform_super_admin}' WHERE email = 'xxx@xxx.com';

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 01:17:27 -08:00
hailin 0ab7261129 feat(auth): introduce platform_admin role with proper access separation
新增 platform_admin 角色,将平台超管与租户管理员的权限彻底分离。

## 后端变更

### auth-service — role-type.vo.ts
- 新增 RoleType.PLATFORM_ADMIN = 'platform_admin'
- DEFAULT_ROLE_PERMISSIONS 中为 PLATFORM_ADMIN 添加空权限集(平台层操作,不参与租户内权限体系)

### auth-service — tenant.controller.ts
- 移除类级别 @Roles('admin'),改为方法级别精细控制:
  - 租户 CRUD(列表/创建/GET/:id/PATCH/:id/PUT/:id/DELETE/:id)→ @Roles('platform_admin')
  - 成员管理(listMembers/updateMember/removeMember)→ @Roles('admin')
  - 邀请管理(listInvites/createInvite/revokeInvite)→ @Roles('admin')
  - 租户管理员可继续管理自己团队的成员和邀请,但无法访问跨租户的租户 CRUD

### auth-service — user.controller.ts
- /api/v1/auth/users(跨租户用户列表/CRUD)→ @Roles('platform_admin')
- 原来任意 admin 均可查看所有用户,现仅平台超管可访问

### version-service — guards/platform-admin.guard.ts(新文件)
- 新增 PlatformAdminGuard:从 Authorization: Bearer <JWT> 中 base64 解码 payload,
  检查 roles 包含 'platform_admin'(无需重复验签,Kong 已完成签名校验)
- 不依赖 @nestjs/passport,轻量、无额外依赖

### version-service — version.controller.ts
- 整个 /api/v1/versions 控制器挂载 @UseGuards(PlatformAdminGuard)
- App 版本管理(上传/发布/删除 APK/IPA)仅平台超管可操作

## 前端变更

### it0-web-admin — sidebar.tsx
- 登录时从 localStorage.user.roles 检测是否为 platform_admin
- 平台超管侧边栏:仪表盘 / 租户管理 / 用户(跨租户)/ App版本 / 账单(套餐+概览+账单记录)/ 设置
- 租户用户侧边栏:仪表盘 / Agent配置 / Runbooks / 常驻指令 / 服务器 / 监控 / 终端 / 安全 / 审计 / 通信 / 账单(概览+账单记录,无套餐管理)/ 设置

## 创建第一个平台超管账号
直接更新数据库:
  UPDATE it0_t_default.users SET roles = '{platform_admin}' WHERE email = 'xxx@xxx.com';
或通过已有 platform_admin 账号调用 POST /api/v1/auth/users 并指定 role: 'platform_admin'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 00:57:40 -08:00
hailin ecc64e0ff9 fix(stt): always use Whisper auto language detection, remove app language hint
Whisper detects language from audio content — speaks Chinese gets Chinese,
speaks English gets English. App language setting is irrelevant to STT.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 00:03:58 -08:00
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