Compare commits

..

No commits in common. "main" and "v0.11.0" have entirely different histories.

203 changed files with 3389 additions and 8611 deletions

3
.gitignore vendored
View file

@ -26,9 +26,6 @@ lerna-debug.log*
*.local
# e.g. Claude Code settings.local.json
*.local.*
.env
.env.*
!.env.example
# Audio binaries
*.pcm

View file

@ -112,17 +112,17 @@
### PostHog前端 / 外部数据源,产品侧)
已接入:
- 前端 `posthog-js` 通过 `packages/stage-ui/src/stores/analytics/posthog.ts` 初始化web / desktop / pocket / auth / docs 共用一个 project key`app_surface` 区分运行端。
- Server 先把产品事实写入 `product_events`,再异步 best-effort 转发注册、支付、订阅等白名单业务事实到 PostHogLLM / TTS per-request 事件不转发PostHog 失败也不能影响请求主链路
- 前端 `posthog-js` 通过 `packages/stage-ui/src/stores/analytics/posthog.ts` 初始化,三个 appweb / desktop / pocket`isStageTamagotchi()` 等选 project key
- Server 不接入 `posthog-node`。后端产品事件写 `product_events`Grafana 展示低基数聚合;需要 PostHog revenue/person analytics 时优先用 PostHog Stripe source connector 或离线导入,不允许在 API 请求路径同步发 PostHog
- 前端 identity`useSharedAnalyticsStore.initialize()` watch `authStore.isAuthenticated` 自动调 `posthog.identify(user.id)` / `reset()`
- 平台统一写入 `app_surface``entry_surface` 只表示 `settings_flux` 这类业务入口,避免同名字段混用或覆盖 PostHog super property
- Conversation controls 事件的 `surface``packages/stage-ui/src/composables/use-analytics.ts` 统一按 runtime 推断UI 调用点只传业务字段
已埋点:
| 域 | 事件 | 来源 | 落点 | Truth |
|---|---|---|---|---|
| 付费漏斗 | `pricing_page_viewed` / `plan_selected` / `checkout_started` | 前端 | `packages/stage-pages/src/pages/settings/flux.vue` | PostHog |
| 付费漏斗终点 | `payment_completed` | 后端 webhook | `product_events` + Grafana,并 best-effort 转发 PostHog | Postgres |
| 付费漏斗终点 | `payment_completed` | 后端 webhook | `product_events` + GrafanaPostHog 走 Stripe source connector/离线导入 | Postgres |
| Activation / Retention | `first_model_selected` / `model_switched` | 前端consciousness store watcher | `packages/stage-ui/src/stores/analytics/index.ts` | PostHog |
| Retention | `character_created` | 前端 | `apps/stage-web/src/pages/settings/characters/components/CharacterDialog.vue` | PostHog |
| Retention | `chat_session_started` | 前端 | `packages/stage-ui/src/components/scenarios/chat/components/sessions-drawer.vue` | PostHog |
@ -130,16 +130,13 @@
| Conversation controls | `chat_message_deleted` / `chat_messages_cleared` / `chat_message_retried` | 前端 | `packages/stage-layouts/src/components/Layouts/*InteractiveArea.vue` / `packages/stage-layouts/src/components/Widgets/ChatActionButtons.vue` / `apps/stage-tamagotchi/src/renderer/components/InteractiveArea.vue` | PostHog |
| Conversation controls | `tts_stop_clicked` | 前端 | `packages/stage-layouts/src/composables/useStopSpeakingButton.ts` | PostHog |
| Churn | `subscription_cancelled`(带 cancellation_reason | 外部 Stripe 数据源 | PostHog Stripe source connector | Stripe/Postgres |
| 老事件 | `provider_card_clicked` | 前端 | `packages/stage-ui/src/composables/use-analytics.ts` | PostHog |
| 兼容指标 | `first_message_sent` | 前端 | 仍有生产者,仅供历史 dashboard新激活口径使用 `chat_activation_succeeded` | PostHog |
| 聊天轮次 | `message_send_started` / `message_sent` / `llm_*` / `message_round` / `message_round_failed` | 前端 core runtime | 以 `conversation_id` / `round_id` / `turn_index` 关联;成功和失败各有唯一终点事件 | PostHog |
| 注册 UI | `signup_form_completed` | auth SPA | 匿名表单完成信号,不计作注册事实 | PostHog |
| 注册事实 | `signup_completed` | Better Auth user create hook | 仅服务端生产,以 Better Auth user id 识别 | Postgres + PostHog |
| 老事件 | `provider_card_clicked` / `first_message_sent` | 前端 | `packages/stage-ui/src/composables/use-analytics.ts` | PostHog |
待埋点API 已在 `use-analytics.ts` 暴露但调用点未接入):
| 域 | 事件 | 状态 |
|---|---|---|
| Activation | `user_signed_up` | 等接到 auth callback 完成事件Better Auth 的 signUp 成功 hook |
| Retention | `voice_mode_activated` | 需要先在 hearing store 加显式 `enableVoiceMode` action — 当前 hearing 没有单一"用户主动启用"那一刻的 trigger被动监听 + 录音 action 不构成 user intent 信号 |
| Feature adoption | `flux_image_generated` | 等图片生成 feature 上线 |
@ -153,21 +150,22 @@
## PostHog 接入路线图
落地分两步,**不要一次性埋全部事件**,否则 schema 漂移会很快出现。PostHog 采集以前端为主;服务端只经 product-events 白名单转发业务事实注册、支付、订阅per-request 路径仍只写 Postgres/Grafana。
落地分两步,**不要一次性埋全部事件**,否则 schema 漂移会很快出现。PostHog 采集只发生在前端或外部 source connectorserver 请求路径只写 Postgres/Grafana。
### 阶段 1P0 — 付费漏斗 + activation
所有运行端共用根目录 `posthog.config.ts`(单一 project key`app_surface` super property 区分端)。初始化实况
`apps/stage-web`
```ts
import { DEFAULT_POSTHOG_CONFIG, POSTHOG_PROJECT_KEY } from '../posthog.config'
import posthog from 'posthog-js'
// DEFAULT_POSTHOG_CONFIG 内含 defaults: '2025-05-24'
// SPA 路由切换自动发 $pageview / $pageleave页面浏览不再手动埋。
posthog.init(POSTHOG_PROJECT_KEY, { ...DEFAULT_POSTHOG_CONFIG })
// 登录后stage 端在 analytics storeauth 端在 profile.vue
posthog.init(import.meta.env.VITE_POSTHOG_KEY, {
api_host: 'https://us.i.posthog.com',
capture_pageview: false, // 手动 capture 控制语义
})
// 登录后
posthog.identify(user.id)
// 在 flux.vue
// 在 pricing.vue
posthog.capture('pricing_page_viewed', { plan_period, source })
```
@ -179,20 +177,20 @@ posthog.capture('pricing_page_viewed', { plan_period, source })
import posthog from 'posthog-js/dist/module.full.no-external.js'
posthog.init(import.meta.env.VITE_POSTHOG_KEY, {
api_host: 'https://t.airi.build',
api_host: 'https://us.i.posthog.com',
autocapture: false, // 桌面应用没有传统 URL 路由,手动控制
})
```
埋点事件清单P0
- 前端:`pricing_page_viewed``plan_selected``checkout_started``signup_form_completed`ui-server-auth 匿名邮箱表单里程碑)`first_model_selected`
- 后端:`product_events` 是事实账本;其中业务事实白名单(`signup_completed``payment_completed``subscription_started/renewed/cancelled`)由 product-events 服务经 posthog-node 转发一份到 PostHog`apps/server/src/services/domain/product-events.ts`distinctId = Better Auth user id。LLM / TTS 等 per-request 事件不转发
- 前端:`pricing_page_viewed``plan_selected``checkout_started``user_signed_up`、`first_message_sent``first_model_selected`
- 后端:不直接发 PostHog`payment_completed` 写入 `product_events`PostHog 侧通过 Stripe source connector 或离线导入展示
PostHog UI 配两个 funnel
- **付费漏斗** (7d 窗口)`pricing_page_viewed → plan_selected → checkout_started → payment_completed`(最后一步来自服务端转发
- **激活漏斗** (14d 窗口)`signup_completed → onboarding_started → chat_activation_succeeded → payment_completed`
- **付费漏斗** (7d 窗口)`pricing_page_viewed → plan_selected → checkout_started → payment_completed`(最后一步来自 Stripe source connector / 离线导入
- **激活漏斗** (14d 窗口)`user_signed_up → first_message_sent → first_model_selected → payment_completed`(最后一步同上)
### 阶段 2P1 — retention / feature adoption / churn

View file

@ -60,7 +60,7 @@ Steps:
Breakdowns:
- `provider_mode`
- `app_surface`
- `surface`
Filters:
@ -116,26 +116,6 @@ Watch for:
- `failure_stage = model_list`
- `failure_stage = llm_response`
### Insight 2a: All Message Round Failures
Type: Trends
Event:
- `message_round_failed`
Breakdowns:
- `failure_stage`
- `error_code`
- `provider_id`
- `app_surface`
Watch for:
- Failures where `turn_index > 1`, which are intentionally outside the activation-failure series.
- Repeated failures for the same `conversation_id` with different `round_id` values.
### Insight 3: Provider Configuration Health
Type: Funnel
@ -233,7 +213,7 @@ Breakdowns:
- `stt_provider_id`
- `error_code`
- `app_surface`
- `surface`
### Insight 7: Feedback And Bug Reports
@ -249,7 +229,7 @@ Breakdowns:
- `category`
- `severity`
- `entrypoint`
- `app_surface`
- `surface`
Watch for:

View file

@ -127,8 +127,7 @@ Grafana 当前不能回答:
| Field | Values | Notes |
|---|---|---|
| `app_surface` | `web` / `electron` / `mobile` / `auth` / `docs` / `server` | 所有关键事件的平台 / 运行端 |
| `entry_surface` | `settings_flux` / `onboarding` / `chat_toolbar` 等受控枚举 | 业务入口;不得用于表示运行端 |
| `surface` | `web` / `desktop` / `mobile` | 所有关键前端事件必带 |
| `provider_mode` | `official` / `custom` / `unknown` | 官方开箱即用 vs 用户自配置 |
| `provider_id` | 白名单 ID | 不传 raw URL / raw key / 用户输入 |
| `model_id` | 白名单或归一化后的 ID | 自定义模型用 `is_custom_model = true` |
@ -148,7 +147,6 @@ Grafana 当前不能回答:
| `chat_activation_started` | frontend | PostHog | 用户进入首次聊天路径或点击发送第一条消息前 |
| `chat_activation_succeeded` | frontend | PostHog | 首次消息完成并看到 assistant response |
| `chat_activation_failed` | frontend | PostHog | 首次消息未完成,包含配置、网络、鉴权、余额、模型等失败 |
| `message_round_failed` | frontend | PostHog | 任意用户轮次在 assistant response 完成前失败;成功轮次的唯一终点仍为 `message_round` |
| `official_provider_selected` | frontend | PostHog | 官方 Provider 被默认落地或在设置页被手动选择,记录 provider id 与是否自动选择 |
| `second_turn_started` | frontend | PostHog | 同一会话开始第二轮对话 |
@ -159,13 +157,11 @@ Grafana 当前不能回答:
| `provider_mode` | yes | `official` / `custom` |
| `provider_id` | yes | 归一化 ID |
| `model_id` | yes | 归一化 ID |
| `app_surface` | yes | web / electron / mobile |
| `conversation_id` | yes | 应用会话 ID同一 conversation 的聊天事件保持一致 |
| `round_id` | yes | 单轮关联 ID复用该轮 user message id同一轮所有聊天主链路事件保持一致 |
| `turn_index` | yes | conversation 内从 `1` 开始的用户轮次;`second_turn_started` 固定为 `2` |
| `surface` | yes | web / desktop / mobile |
| `time_to_first_message_ms` | success only | 从 app start 或 onboarding complete 到首次成功 |
| `error_code` | failed only | 稳定错误码 |
| `failure_stage` | failed only | `provider_config` / `model_list` / `message_send` / `llm_response` / `tts` |
| `turn_index` | second turn only | 固定为 `2`,用于首轮成功后的二轮启动 |
| `auto_selected` | official provider only | 官方默认 Provider 自动落地时为 `true` |
推荐看板:
@ -173,42 +169,12 @@ Grafana 当前不能回答:
- 新用户 `chat_activation_started -> chat_activation_succeeded` 漏斗。
- 按 `provider_mode` 拆分 activation conversion。
- `chat_activation_failed``failure_stage` / `provider_id` 排名。
- `message_round_failed``turn_index` / `failure_stage` / `provider_id` 排名,用于分析激活后的聊天失败。
异常提醒:
- 新用户 activation conversion 24h 环比下降超过 15%。
- `provider_mode = official` 的 activation failure 上升,优先排查官方 Provider。
### AI generation 用量事实
`$ai_generation` 是 token / usage / 成本覆盖率事实来源;不要把 Prompt、回复正文、API Key、raw endpoint 发到 PostHog。
| Field | Required | Notes |
|---|---|---|
| `$ai_trace_id` | yes | 优先使用真实 `conversation_id`;无客户端会话 header 时用 server request id 兜底 |
| `$ai_session_id` | yes | 与 `conversation_id` 保持一致 |
| `$ai_span_id` | yes | 与 `round_id` / generation id 保持一致 |
| `$ai_model` | yes | 原始生成模型;不要跨 Provider 合并 |
| `$ai_provider` | yes | 实际生成 Provider |
| `airi_user_id` | server yes | Better Auth user id便于和 Pro / 付费事件关联 |
| `conversation_id` | yes | 始终存在;结合 `conversation_id_source` 判断是否真实应用会话 |
| `conversation_id_source` | yes | `client_header` / `client_runtime` / `server_request` |
| `round_id` | yes | 单轮或 request-level generation id |
| `app_surface` | when known | 只表示用户产品端:`web` / `electron` / `mobile`;不要用 `server` 兜底 |
| `capture_surface` | yes | 事件采集端:`client` / `server` |
| `usage_source` | yes | `reported` / `estimated` / `unavailable` |
| `token_usage_available` | yes | token 是否可用于聚合;日报 token 分析先过滤 `true` |
| `cost_usd_source` | yes | `reported` / `estimated` / `unavailable` |
| `cost_usd_known` | yes | `false` 不能按零成本处理,只能计入未知成本覆盖率 |
日报里的 Pro token / cost
- Token 总量、P50、P90只统计 `token_usage_available = true`
- AIRI USD 成本:只统计 `cost_usd_known = true``cost_usd_known = false` 单独报 unknown generation count / coverage。
- 服务端 request fallback`conversation_id_source = server_request` 只能做 request 级 usage不能和 `message_round` 当成同一应用会话 join。
- 模型分析按 `$ai_provider + $ai_model` 看;不要新增 `canonical_model` / `model_family` 把不同供应链揉在一起。
### Provider And Model Configuration
目标:定位配置复杂和失败劝退。
@ -316,14 +282,14 @@ Grafana 当前不能回答:
| Field | Required | Notes |
|---|---|---|
| `stt_provider_id` | yes | 归一化 ID |
| `app_surface` | yes | web / electron / mobile |
| `surface` | yes | web / desktop / mobile |
| `duration_ms` | no | 用户按住或录音时长 |
| `error_code` | failed only | `permission_denied` / `device_unavailable` / `provider_error` / `timeout` |
推荐看板:
- Voice input start -> STT success funnel。
- Permission denied rate by browser / app surface。
- Permission denied rate by browser / surface。
- STT failure rate by Provider。
### Feedback And Bug Reports
@ -346,7 +312,7 @@ Grafana 当前不能回答:
| `severity` | yes | `blocker` / `major` / `minor` / `suggestion` |
| `user_type` | yes | `new_user` / `paid_user` / `overseas_user` / `developer_user` / `role_chat_user` / `unknown` |
| `entrypoint` | yes | `about_update_error` / `community_manual_tag` 等低基数入口 |
| `app_surface` | in-app | web / electron / mobile |
| `surface` | in-app | web / desktop / mobile |
| `provider_mode` | no | 可从最近一次配置状态补齐 |
| `description_length_bucket` | bug report | `empty` / `short` / `medium` / `long`,不要上传正文 |
| `include_triage_context` | bug report | 是否附带页面上下文 |
@ -429,7 +395,7 @@ Grafana 当前不能回答:
- `has_voice`
- `latency_ms`
- `error_code`
- `app_surface`
- `surface`
看板:
@ -454,7 +420,7 @@ Grafana 当前不能回答:
- `character_type`: `built_in` / `imported` / `custom`
- `has_voice`
- `voice_type`
- `app_surface`
- `surface`
看板:
@ -472,7 +438,7 @@ Grafana 当前不能回答:
字段:
- `entry_surface`(付费入口,例如 `settings_flux`;运行端使用 `app_surface`
- `surface`
- `balance_state`
- `plan_id`
- `currency`
@ -491,8 +457,6 @@ Grafana 当前不能回答:
|---|---|---|
| `first_message_sent` | 保留历史指标 | 继续用于老 dashboard新激活口径用 `chat_activation_succeeded` |
| `chat_activation_started` / `chat_activation_succeeded` / `chat_activation_failed` | 新核心 activation 口径 | 用来回答“用户能不能正常开始聊天” |
| `message_send_started` / `message_sent` / `llm_*` / `message_round` / `message_round_failed` | 聊天主链路 | 共享 `conversation_id` / `round_id` / `turn_index``message_round``message_round_failed` 分别是单轮成功 / 失败的唯一终点;不再重复发送 `chat_started``assistant_response_completed``chat_failed` 或 chat 的通用 `feature_used` 别名 |
| `signup_form_completed` / `signup_completed` | UI 里程碑 / 注册事实 | 前者可匿名;后者只由服务端按 Better Auth user id 发送,禁止复用同名客户端事件 |
| `provider_card_clicked` | 保留入口点击 | 不等于配置成功;成功 / 失败看 `provider_config_succeeded` / `provider_config_failed` |
| `first_model_selected` / `model_switched` | 保留模型选择行为 | 配置链路和模型列表健康看 `model_list_loaded` / `model_list_failed` |
| `stt_started` / `stt_succeeded` / `stt_failed` | 保留 STT Provider 结果 | 权限和设备问题用新增 `microphone_*` / `audio_device_unavailable` 拆开 |
@ -509,9 +473,9 @@ PostHog 线上已经能看到 `model` / `model_id` 存在自由文本风险。
- Provider、model、voice 使用稳定 ID。
- 自定义值不要直接 group-by。
- 不跨 Provider 合并模型名:`deepseek-chat``deepseek/deepseek-chat` 可能代表不同供应链 / 成本口径,日报按原始 Provider + model 组合看。
- 自定义模型传:
- `provider_id = custom`
- `model_family = custom`
- `is_custom_model = true`
- `custom_model_hash` 可选,必须单向 hash不能还原原文。
- 自定义 voice 传:
@ -531,8 +495,7 @@ PostHog 线上已经能看到 `model` / `model_id` 存在自由文本风险。
| `voice_pack_id` | medium | 只进 PostHog / Postgres不进 Prometheus label |
| `error_code` | low | enum |
| `source` | low | enum |
| `app_surface` | low | runtime enum |
| `entry_surface` | low | 低基数业务入口 enum不得复用为 runtime |
| `surface` | low | enum |
Prometheus label 不放 `user_id``session_id``voice_pack_id`、自定义模型名、自定义音色名。
@ -710,7 +673,7 @@ Prometheus label 不放 `user_id`、`session_id`、`voice_pack_id`、自定义
- Top failing providers: <list>
- Top failing error codes: <list>
- Rage-click pages / app surfaces: <list>
- Rage-click pages / surfaces: <list>
- Discord / QQ feedback categories:
- performance: <count>
- config: <count>
@ -746,7 +709,7 @@ Prometheus label 不放 `user_id`、`session_id`、`voice_pack_id`、自定义
给负责上手体验的人看:
- Funnel`app_loaded -> chat_activation_started -> provider_config_succeeded -> model_list_loaded -> chat_activation_succeeded`
- Breakdown`provider_mode``app_surface`、`region`
- Breakdown`provider_mode``surface`、`region`
- TableTop `provider_config_failed` by `provider_id` / `error_code`
- Timeseries`time_to_first_message_ms` p50 / p95
@ -766,7 +729,7 @@ Prometheus label 不放 `user_id`、`session_id`、`voice_pack_id`、自定义
- Grafana5xx、LLM latency、provider failure、TTS blocked
- PostHograge-click trend、failed frontend events
- TableTop error_code by app surface / provider
- TableTop error_code by surface / provider
- Community tagsDiscord / QQ 反馈分类趋势
### 分析方法
@ -800,7 +763,7 @@ Prometheus label 不放 `user_id`、`session_id`、`voice_pack_id`、自定义
- `chat_activation_failed` by `failure_stage`
- `provider_config_failed` by `error_code`
- `model_list_failed` by `provider_id`
- `$rageclick` by page / `app_surface`
- `$rageclick` by page / surface
- Discord / QQ `category = bug` 的高频词
日报只报异常;周报把异常和社区反馈合并成“优先修复建议”。
@ -900,14 +863,11 @@ Prometheus label 不放 `user_id`、`session_id`、`voice_pack_id`、自定义
10. 建异常检查activation、provider config、model list、STT、TTS blocked、bug report。
11. 建周报模板:自动填指标,社区负责人补充 Discord / QQ 反馈解释和下周建议。
### 当前接入状态2026-07-10
### 当前接入状态2026-06-30
已接入代码:
- Chat activation`chat_activation_started``chat_activation_succeeded``chat_activation_failed` 只覆盖每个 conversation 首次 assistant response 之前的尝试;后续轮次继续发 message / latency events第二轮单独发 `second_turn_started`
- Chat correlation每次发送以 user message id 作为 `round_id`activation、message、LLM latency、render、`message_round``message_round_failed` 事件共享 `conversation_id``round_id``turn_index`
- Identity匿名 auth SPA 发 `signup_form_completed`;只有服务端 Better Auth user create hook 发 identified `signup_completed`。平台统一使用 `app_surface`,业务入口统一使用 `entry_surface`
- Chat event reuse主链路使用 `message_send_started``message_sent``llm_*``message_round``message_round_failed`;每轮成功 / 失败各自只有一个终点事件,不再发送 `chat_started``assistant_response_completed``chat_failed` 和 chat 的通用 `feature_used` 别名。
- Chat activation`chat_activation_started``chat_activation_succeeded``chat_activation_failed``second_turn_started`;官方 Provider 选择事件为 `official_provider_selected`,实际聊天使用口径看 activation events 的 `provider_mode = official`
- Model list`model_list_loaded``model_list_failed`
- Provider config`provider_config_started``provider_config_succeeded``provider_config_failed`
- TTS voice`tts_provider_selected``voice_selected``voice_preview_played``voice_pack_bound``official_tts_exposed``official_tts_preview_started``official_tts_preview_succeeded``official_tts_auto_enabled`

View file

@ -1,26 +0,0 @@
# Verification: 服务端 PostHog 转发 + SPA 路由 pageview
Status: **transport 与 pageview 已真实验证;线上 Stripe webhook 端到端待部署后确认**
Owner: Product Analytics
Last updated: 2026-07-08
Environment: commit 689f02ac4 + 本次工作区改动posthog-node 5.39.4posthog-js 1.306.1Node 26.3.0
## 用户路径
- **场景 1**:用户完成 Stripe 支付 → webhook 写 `product_events` → 服务端把 `payment_completed` 转发到 PostHogdistinctId = Better Auth user id→ PostHog 付费漏斗在 `checkout_started` 后闭环。
- **场景 2**:用户在 stage-web 内切换路由(如进入 `/settings/flux`)→ PostHog 收到 `$pageview`,带 `$pathname`、上一页路径与停留时长。
## 已验证证据
| 验证项 | 命令 / 方式 | 实际输出 |
|---|---|---|
| 转发白名单与映射 | `pnpm exec vitest run src/services/domain/product-events.test.ts`apps/server | 6 passed`payment_completed` 原名转发、`user_signed_up→signup_completed` 映射、per-request 动作不转发、sink 抛错时 DB 行仍落库且 track 不抛 |
| posthog-node 真实传输 | `node posthog-smoke.mjs`captureImmediate → us.i.posthog.com生产 project key事件名 `server_forwarding_smoke_test` | `captureImmediate resolved in 1380ms` + `shutdown clean` |
| SPA 路由 pageview | `VITE_ENABLE_POSTHOG=true pnpm -F @proj-airi/stage-web dev` + agent-browser 两次 `history.pushState` | 两条 `$pageview``$pathname` 分别为 `/settings/flux``/settings/airi-card``navigation_type: pushState`,携带 `$prev_pageview_duration``app_surface: web` super property批量 POST `us.i.posthog.com/e/` 返回 200 |
| 服务端 typecheck / lint | `pnpm -F @proj-airi/server typecheck`、eslint 改动文件 | 均通过 |
## 注意事项
- 自动化浏览器(`navigator.webdriver = true`)会被 posthog-js 默认 bot 过滤静默丢弃事件;本次浏览器验证通过会话内 `set_config({ opt_out_useragent_filter: true })` 绕过,仅影响该验证会话,生产配置未改。人工复测时用普通浏览器即可,无需任何绕过。
- 服务端转发默认开启:`POSTHOG_PROJECT_KEY` 的默认值就是前端共用的 phc_* project key置空字符串可关闭。部署后在 PostHog 里确认 `payment_completed` 事件出现在真实支付后,即完成端到端收尾。
- PostHog 项目里会留有一条 `server_forwarding_smoke_test`distinctId `verification-smoke`)测试事件,分析时按事件名过滤。

View file

@ -2,7 +2,7 @@
Status: **code-level instrumentation verified; live PostHog dashboard updated; Grafana dashboard updated; alert setup pending**
Owner: Community / Product Analytics
Last updated: 2026-07-10
Last updated: 2026-07-01
Related:
- [`product-analytics-instrumentation.md`](../product-analytics-instrumentation.md)
- [`product-analytics-dashboard-setup.md`](../product-analytics-dashboard-setup.md)
@ -20,7 +20,6 @@ Related:
|---|---|
| Frontend analytics API | `packages/stage-ui/src/composables/use-analytics.test.ts` 覆盖 activation、model list、provider config、voice selection、voice input、feedback event API |
| Chat activation hooks | `packages/core-agent/src/runtime/chat-orchestrator-runtime.test.ts` 覆盖 activation started / succeeded / failed hook |
| Chat round failures | core runtime 与 stage contract tests 覆盖激活前和激活后的 `message_round_failed`,并验证 `conversation_id` / `round_id` / `turn_index` |
| Voice input failures | `packages/stage-ui/src/composables/audio/audio-device.test.ts``packages/stage-ui/src/stores/modules/hearing.analytics.test.ts` 覆盖 permission / device / cancel / STT failed |
| Server TTS metadata | `apps/server/src/routes/openai/v1/route.test.ts``apps/server/src/routes/audio-speech-ws/route.test.ts` 覆盖 REST / WS TTS `voice_id``voice_type``voice_pack_id` metadata |
| Grafana product row | `apps/server/otel/grafana/dashboards/build.test.ts` 覆盖 Product Analytics panels、layout references、PromQL 不包含 high-cardinality voice / user fields |
@ -52,10 +51,8 @@ Required properties:
provider_mode = official
provider_id = <official provider id>
model_id = <selected model id>
app_surface = web | mobile | electron
conversation_id = <same application session id across the chat chain>
round_id = <same id across one message round; different between the first and second round>
turn_index = 1 for the first round; 2 for second_turn_started and the second round
surface = web | mobile | electron
turn_index = 2
```
Fail if:
@ -63,52 +60,9 @@ Fail if:
- `chat_activation_started` appears but `chat_activation_succeeded` never appears for a successful chat.
- The second message is sent but `second_turn_started` does not appear.
- `provider_mode` is missing or always `unknown`.
- `app_surface` is missing.
- Any chat-chain event is missing `conversation_id`, `round_id`, or `turn_index`.
- Events from one round disagree on `round_id`, or two different rounds reuse the same `round_id`.
- `surface` is missing.
### 1a. PostHog: message round failure
Action:
1. Complete a successful first chat round.
2. Force the second round to fail before the assistant response completes.
Expected PostHog events:
```text
message_round_failed
```
Required properties:
```text
conversation_id = <same application session id as the successful first round>
round_id = <the failed round's user message id>
turn_index = 2
provider_id = <active provider id>
model_id = <selected model id>
failure_stage = llm_response
error_code = llm_response_failed
app_surface = web | mobile | electron
```
Fail if:
- The failed second round has no `message_round_failed` event.
- The failed round emits `message_round`, `chat_failed`, or `assistant_response_completed` as an alias.
- A new `chat_activation_failed` appears after the conversation already completed its first assistant response.
- Correlation keys disagree with the failed round's preceding message / LLM events.
### 1b. Signup identity ownership
1. Complete an email signup in the auth SPA.
2. Confirm the auth SPA emits `signup_form_completed` with `app_surface = auth`.
3. Confirm the Better Auth user-create hook emits exactly one `signup_completed` with `app_surface = server` and the Better Auth user id as `distinctId`.
Fail if the auth SPA emits `signup_completed`, or if the server event lands on a different PostHog person from later identified onboarding events.
### 1c. PostHog: official provider selection
### 1b. PostHog: official provider selection
Action:
@ -265,7 +219,7 @@ paywall_seen
Required properties:
```text
entry_surface = settings_flux
surface = settings_flux
reason = manual_topup
flux_balance_bucket = zero | 1_100 | 101_1000 | 1001_10000 | 10000_plus | unknown
```

View file

@ -58,12 +58,14 @@ describe('grafana dashboard builder', () => {
* @example
* expect(panelTitle('panel-99')).toBe('TTS Success %')
*/
it('keeps the product analytics row focused on Prometheus-safe engagement signals', () => {
it('keeps the product analytics row focused on server-side TTS health', () => {
expect(panelTitle('panel-95')).toBe('Product Events (range)')
expect(panelTitle('panel-96')).toBe('Product Failure %')
expect(panelTitle('panel-97')).toBe('Top Product Actions (range)')
expect(panelTitle('panel-98')).toBe('Product Event Rate')
expect(panelTitle('panel-99')).toBe('日活')
expect(panelTitle('panel-99')).toBe('TTS Success %')
expect(panelTitle('panel-100')).toBe('TTS Failed / Blocked (range)')
expect(panelTitle('panel-101')).toBe('TTS Event Rate by Source')
expect(panelTitle('panel-102')).toBe('TTS Blocked by Reason')
expect(panelTitle('panel-103')).toBe('TTS Blocked by Flux Bucket')
})
/**
@ -78,6 +80,10 @@ describe('grafana dashboard builder', () => {
dashboard.elements['panel-97'],
dashboard.elements['panel-98'],
dashboard.elements['panel-99'],
dashboard.elements['panel-100'],
dashboard.elements['panel-101'],
dashboard.elements['panel-102'],
dashboard.elements['panel-103'],
]).join('\n')
expect(productPanelExpressions).not.toContain('voice_id')

View file

@ -34,7 +34,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
const PROM = { name: 'grafanacloud-projairi-prom' }
const LOKI = { name: 'grafanacloud-projairi-logs' }
const SCHEMA_VERSION = '13.2.0-28666480772'
const SCHEMA_VERSION = '13.0.0-23630096546'
// Service / env filter applied to every Prom query. Pulled into a helper so
// the variable name only appears once.
@ -62,10 +62,9 @@ function query(expr: string, legend: string, refId = 'A', datasource: DataSource
group: datasource === LOKI ? 'loki' : 'prometheus',
kind: 'DataQuery',
spec: {
editorMode: 'code',
expr,
legendFormat: legend,
...(opts.instant ? { instant: true, range: false } : { range: true }),
...(opts.instant && { instant: true, range: false }),
},
version: 'v0',
},
@ -129,8 +128,6 @@ interface TimeseriesPanelOpts {
stack?: boolean
fillOpacity?: number
legendCalcs?: LegendCalc[]
legendPlacement?: 'bottom' | 'right'
legendDisplayMode?: 'list' | 'table'
}
// `noValue` shows a friendly placeholder instead of "No data" red text when
@ -272,14 +269,7 @@ function barGaugePanel(id: number, title: string, description: string, queries:
}
function timeseriesPanel(id: number, title: string, description: string, queries: PanelQuery[], opts: TimeseriesPanelOpts = {}) {
const {
unit = 'short',
stack = false,
fillOpacity = 20,
legendCalcs = ['lastNotNull', 'max'],
legendPlacement = 'right',
legendDisplayMode = 'table',
} = opts
const { unit = 'short', stack = false, fillOpacity = 20, legendCalcs = ['lastNotNull', 'max'] } = opts
return {
kind: 'Panel',
spec: {
@ -328,14 +318,7 @@ function timeseriesPanel(id: number, title: string, description: string, queries
// Show last + max in the legend table so viewers don't have to
// click each line to see numbers — same trick as Keycloak's
// "Login Errors" panel.
legend: {
calcs: legendCalcs,
displayMode: legendDisplayMode,
enableFacetedFilter: false,
overflow: 'ellipsis',
placement: legendPlacement,
showLegend: true,
},
legend: { calcs: legendCalcs, displayMode: 'table', placement: 'right', showLegend: true },
tooltip: { hideZeros: false, mode: 'multi', sort: 'desc' },
},
},
@ -345,119 +328,6 @@ function timeseriesPanel(id: number, title: string, description: string, queries
}
}
function pieChartPanel(id: number, title: string, description: string, queries: PanelQuery[], unit = 'short') {
return {
kind: 'Panel',
spec: {
data: { kind: 'QueryGroup', spec: { queries, queryOptions: {}, transformations: [] } },
description,
id,
links: [],
title,
vizConfig: {
group: 'piechart',
kind: 'VizConfig',
spec: {
fieldConfig: {
defaults: {
color: { fixedColor: '#73BF69', mode: 'palette-classic' },
custom: { hideFrom: { legend: false, tooltip: false, viz: false } },
unit,
},
overrides: [],
},
options: {
displayLabels: ['percent'],
legend: { displayMode: 'table', overflow: 'ellipsis', placement: 'bottom', showLegend: true },
pieType: 'pie',
reduceOptions: { calcs: ['sum'], fields: '', values: false },
sort: 'desc',
tooltip: { hideZeros: false, mode: 'single', sort: 'none' },
},
},
version: SCHEMA_VERSION,
},
},
}
}
// Custom Grafana UI panel retained as code so the generated dashboard matches
// the latest hand-tuned cloud version without checking in cloud metadata.
function dailyActiveUsersTrendPanel() {
return {
kind: 'Panel',
spec: {
data: {
kind: 'QueryGroup',
spec: {
queries: [
query(
`max (user_active_rolling{${SERVICE_FILTER}, window="24h"})`,
'__auto',
),
],
queryOptions: {},
transformations: [],
},
},
description: '',
id: 99,
links: [],
title: '日活',
vizConfig: {
group: 'timeseries',
kind: 'VizConfig',
spec: {
fieldConfig: {
defaults: {
color: { mode: 'continuous-GrYlRd', seriesBy: 'last' },
custom: {
axisBorderShow: false,
axisCenteredZero: false,
axisColorMode: 'text',
axisLabel: '',
axisPlacement: 'auto',
barAlignment: 0,
barWidthFactor: 0.6,
drawStyle: 'line',
fillOpacity: 17,
gradientMode: 'scheme',
hideFrom: { legend: false, tooltip: false, viz: false },
insertNulls: false,
lineInterpolation: 'linear',
lineStyle: { fill: 'solid' },
lineWidth: 2,
pointSize: 3,
scaleDistribution: { type: 'linear' },
showPoints: 'auto',
showValues: false,
spanNulls: false,
stacking: { group: 'A', mode: 'none' },
thresholdsStyle: { mode: 'off' },
},
thresholds: thresholds([{ color: 'green', value: 0 }, { color: 'red', value: 80 }]),
},
overrides: [],
},
options: {
annotations: { clustering: -1, multiLane: false },
legend: {
calcs: [],
displayMode: 'list',
enableFacetedFilter: false,
overflow: 'ellipsis',
placement: 'bottom',
showLegend: true,
},
tooltip: { hideZeros: false, mode: 'single', sort: 'none' },
},
},
version: SCHEMA_VERSION,
},
},
}
}
interface HeatmapPanelOpts {
unit?: string
}
@ -707,7 +577,69 @@ elements['panel-98'] = timeseriesPanel(
{ unit: 'eps', fillOpacity: 15 },
)
elements['panel-99'] = dailyActiveUsersTrendPanel()
elements['panel-99'] = gaugePanel(
99,
'TTS Success %',
'Server-side TTS successes divided by TTS requests over the dashboard range. Includes REST and WS TTS product events. Drops here mean users are asking for speech but not receiving audio; inspect failed/blocked panels next.',
[query(
`100 * sum(increase(airi_product_events_total{${PRODUCT_EVENT_FILTER}, feature="tts", action="speech_succeeded", status="succeeded"}[$__range])) / clamp_min(sum(increase(airi_product_events_total{${PRODUCT_EVENT_FILTER}, feature="tts", action="speech_requested", status="started"}[$__range])), 1)`,
'success %',
)],
{ steps: [{ color: 'red', value: 0 }, { color: 'yellow', value: 90 }, { color: 'green', value: 98 }], max: 100, decimals: 2, noValue: '0' },
)
elements['panel-100'] = barGaugePanel(
100,
'TTS Failed / Blocked (range)',
'TTS user-impacting failures over the dashboard range, split by action/status/source. `speech_failed` usually means upstream/runtime failure; `speech_blocked` usually means balance/preflight blocked. Keep voice/model drilldown in Postgres metadata, not Prometheus labels.',
[query(
`topk(12, sum by (action, status, source) (increase(airi_product_events_total{${PRODUCT_EVENT_FILTER}, feature="tts", action=~"speech_failed|speech_blocked"}[$__range])))`,
'{{action}} · {{status}} · {{source}}',
'A',
PROM,
{ instant: true },
)],
{ unit: 'short', noValue: '0' },
)
elements['panel-101'] = timeseriesPanel(
101,
'TTS Event Rate by Source',
'TTS product event rate by source and action. Use this to distinguish chat auto-TTS, manual previews/settings tests, and API audio.speech traffic when speech health changes.',
[query(
`sum by (source, action, status) (rate(airi_product_events_total{${PRODUCT_EVENT_FILTER}, feature="tts"}[$__rate_interval]))`,
'{{source}} · {{action}} · {{status}}',
)],
{ unit: 'eps', fillOpacity: 15 },
)
elements['panel-102'] = barGaugePanel(
102,
'TTS Blocked by Reason',
'Blocked TTS events over the dashboard range, grouped by bounded product reason and source. Today this mostly shows insufficient balance; new policy/provider/preflight buckets can be added without exposing user, voice, or model labels.',
[query(
`topk(12, sum by (reason, source) (increase(airi_product_events_total{${PRODUCT_EVENT_FILTER}, feature="tts", action="speech_blocked", status="blocked"}[$__range])))`,
'{{reason}} · {{source}}',
'A',
PROM,
{ instant: true },
)],
{ unit: 'short', noValue: '0' },
)
elements['panel-103'] = barGaugePanel(
103,
'TTS Blocked by Flux Bucket',
'Blocked TTS events over the dashboard range, grouped by coarse Flux balance bucket. This helps separate truly empty accounts from low-balance accounts without exposing exact user balances.',
[query(
`topk(8, sum by (flux_balance_bucket, source) (increase(airi_product_events_total{${PRODUCT_EVENT_FILTER}, feature="tts", action="speech_blocked", status="blocked", flux_balance_bucket!=""}[$__range])))`,
'{{flux_balance_bucket}} · {{source}}',
'A',
PROM,
{ instant: true },
)],
{ unit: 'short', noValue: '0' },
)
// --- Row 2: HTTP — traffic ranking, error trend, latency trend -------------
elements['panel-16'] = barGaugePanel(
@ -715,7 +647,7 @@ elements['panel-16'] = barGaugePanel(
'Top Routes by Requests (range)',
'Top Hono-matched routes by request count over the dashboard range. The main traffic list: which API surfaces are hottest. Wildcard patterns like `/api/v1/openai/*` are requests that did not reach a concrete handler (404 / auth-rejected); concrete paths are successful routes.',
[query(
`topk(50, sum by (http_route) (increase(http_server_request_duration_seconds_count{${SERVICE_FILTER}, http_request_method!="OPTIONS", http_route!=""}[$__range])))`,
`topk(10, sum by (http_route) (increase(http_server_request_duration_seconds_count{${SERVICE_FILTER}, http_request_method!="OPTIONS", http_route!=""}[$__range])))`,
'{{http_route}}',
'A',
PROM,
@ -726,7 +658,7 @@ elements['panel-16'] = barGaugePanel(
elements['panel-40'] = heatmapPanel(
40,
'Status Distribution',
'Error Rate %',
'HTTP status-code mix over time, one row per status code, colour = request rate in each time bucket. The 200 row dominates in steady state; a 5xx / 4xx row suddenly lighting up flags an incident at a glance. Non-OPTIONS traffic only.',
[query(
`sum by (http_response_status_code) (rate(http_server_request_duration_seconds_count{${SERVICE_FILTER}, http_request_method!="OPTIONS"}[$__rate_interval]))`,
@ -737,15 +669,13 @@ elements['panel-40'] = heatmapPanel(
elements['panel-20'] = timeseriesPanel(
20,
'Request Latency (by Route)',
'',
'HTTP P95 by Route',
'P95 latency per Hono-matched route, excluding /api/v1/openai/* (LLM gateway latency lives in the LLM Gateway row). 404s excluded so missing-route noise does not skew the curve.',
[query(
`sum by (http_route) (
rate(http_server_request_duration_seconds_bucket{${SERVICE_FILTER}, http_request_method!="OPTIONS", http_route!~"/api/v1/openai/.*", http_response_status_code!="404"}[$__rate_interval])
)`,
`histogram_quantile(0.95, sum by (le, http_route) (rate(http_server_request_duration_seconds_bucket{${SERVICE_FILTER}, http_request_method!="OPTIONS", http_route!~"/api/v1/openai/.*", http_response_status_code!="404"}[$__rate_interval])))`,
'{{http_route}}',
)],
{ unit: 's', legendPlacement: 'bottom' },
{ unit: 's' },
)
elements['panel-94'] = timeseriesPanel(
@ -760,7 +690,7 @@ elements['panel-94'] = timeseriesPanel(
)
// --- Row 3: LLM Gateway — request mix + latency ----------------------------
elements['panel-11'] = pieChartPanel(
elements['panel-11'] = timeseriesPanel(
11,
'LLM Request Rate by Model',
'Per-model request rate (chat + tts). Useful for capacity planning and spotting model-routing regressions.',
@ -768,7 +698,7 @@ elements['panel-11'] = pieChartPanel(
`sum by (gen_ai_request_model) (rate(gen_ai_client_operation_count_total{${SERVICE_FILTER}, gen_ai_request_model!=""}[$__rate_interval]))`,
'{{gen_ai_request_model}}',
)],
'reqps',
{ unit: 'reqps' },
)
elements['panel-21'] = timeseriesPanel(
@ -776,7 +706,7 @@ elements['panel-21'] = timeseriesPanel(
'LLM Latency P95',
'Two P95 latency signals for the LLM gateway, aggregated across models. TTFB = time to first streamed token (streaming chat UX). End-to-end = full operation duration — the only latency signal for non-streaming chat and TTS, which have no first-token event.',
[
query(`sum by () (rate(gen_ai_client_first_token_duration_seconds_bucket{${SERVICE_FILTER}}[$__rate_interval]))`, 'TTFB p95', 'A'),
query(`histogram_quantile(0.95, sum by (le) (rate(gen_ai_client_first_token_duration_seconds_bucket{${SERVICE_FILTER}}[$__rate_interval])))`, 'TTFB p95', 'A'),
query(`histogram_quantile(0.95, sum by (le) (rate(gen_ai_client_operation_duration_seconds_bucket{${SERVICE_FILTER}}[$__rate_interval])))`, 'end-to-end p95', 'B'),
],
{ unit: 's' },
@ -955,6 +885,51 @@ elements['panel-32'] = statPanel(
{ unit: 'short', variant: 'count', noValue: '—', graphMode: 'none' },
)
// --- Row 7: Infrastructure (collapsed) — process / DB health ---------------
elements['panel-50'] = statPanel(
50,
'DB Query P95 (5m)',
'PostgreSQL query duration P95 from PgInstrumentation. Fixed 5m window. Spikes correlate with index misses, connection exhaustion, or backend lock contention.',
[query(
`histogram_quantile(0.95, sum by (le) (rate(db_client_operation_duration_seconds_bucket{${SERVICE_FILTER}}[5m])))`,
'p95',
)],
{ unit: 's', steps: [{ color: 'green', value: 0 }, { color: 'yellow', value: 0.05 }, { color: 'red', value: 0.5 }], decimals: 3 },
)
elements['panel-51'] = timeseriesPanel(
51,
'DB Pool Connections by Instance',
'Open PostgreSQL connections, per replica (`service_instance_id`). Each instance has its own pool sized by env `DB_POOL_MAX`. One instance with a permanently-high count = pool leak on that pod.',
[query(
`sum by (service_instance_id) (db_client_connection_count{${SERVICE_FILTER}})`,
'{{service_instance_id}}',
)],
{ unit: 'short' },
)
elements['panel-52'] = timeseriesPanel(
52,
'Heap Used % by Instance',
'V8 heap used ÷ heap limit, per replica (`service_instance_id`). A single replica trending up while others stay flat = leak on that pod.',
[query(
`100 * sum by (service_instance_id) (v8js_memory_heap_used_bytes{${SERVICE_FILTER}}) / clamp_min(sum by (service_instance_id) (v8js_memory_heap_limit_bytes{${SERVICE_FILTER}}), 1)`,
'{{service_instance_id}}',
)],
{ unit: 'percent' },
)
elements['panel-53'] = timeseriesPanel(
53,
'Event Loop Delay P99 by Instance',
'P99 event-loop delay per replica. One replica climbing while others stay flat = CPU-bound work pinning that pod. >50ms sustained is bad anywhere.',
[query(
`max by (service_instance_id) (nodejs_eventloop_delay_p99_seconds{${SERVICE_FILTER}})`,
'{{service_instance_id}}',
)],
{ unit: 's' },
)
// --- Row 8: Logs ------------------------------------------------------------
elements['panel-91'] = logsPanel(
91,
@ -975,53 +950,68 @@ elements['panel-90'] = logsPanel(
// ---------------------------------------------------------------------------
const rows = [
// Row 1: Service Health — dense single-screen operations layout from the
// latest Grafana Cloud edit. Logs stay docked on the right while traffic,
// latency, LLM, token, and provider panels fill the left.
// Row 1: Service Health — two rows of glance stats + the status-code heatmap
// standing tall on the right, with the live WS-connections trend full-width
// underneath. counts (New Users / Active Sessions / WS Online) read blue with
// a trend delta; req-rate + 5xx stay traffic-light.
row('Service Health', [
item('panel-3', 0, 0, 4, 5),
item('panel-5', 4, 0, 4, 5),
item('panel-40', 8, 0, 10, 6),
item('panel-90', 18, 0, 6, 38),
item('panel-93', 0, 5, 4, 5),
item('panel-4', 4, 5, 4, 5),
item('panel-20', 8, 6, 10, 15),
item('panel-92', 0, 10, 4, 10),
item('panel-73', 4, 10, 2, 10),
item('panel-11', 6, 10, 2, 10),
item('panel-16', 0, 20, 8, 18),
item('panel-71', 8, 21, 5, 5),
item('panel-21', 13, 21, 5, 5),
item('panel-69', 8, 26, 5, 6),
item('panel-67', 13, 26, 5, 6),
item('panel-66', 8, 32, 5, 6),
item('panel-68', 13, 32, 5, 6),
item('panel-1', 0, 0, 6, 4),
item('panel-3', 6, 0, 6, 4),
item('panel-4', 12, 0, 6, 4),
item('panel-40', 18, 0, 6, 8),
item('panel-15', 0, 4, 6, 4),
item('panel-5', 6, 4, 6, 4),
item('panel-93', 12, 4, 6, 4),
item('panel-92', 0, 8, 24, 5),
]),
// Row 2: User Engagement — rolling-window active users and Prom-safe product
// analytics. The hand-tuned "日活" trend gives the row a visual engagement
// anchor while compact stats keep user/session totals nearby.
// Row 2: User Engagement — rolling-window active users (DAU/WAU/MAU) from
// user.last_seen_at. Kept its own row so it can grow (retention, cohorts)
// without crowding the health glance above.
row('User Engagement', [
item('panel-80', 0, 0, 3, 5),
item('panel-1', 3, 0, 3, 5),
item('panel-99', 6, 0, 12, 11),
item('panel-98', 18, 0, 6, 11),
item('panel-82', 0, 5, 3, 3),
item('panel-15', 3, 5, 3, 6),
item('panel-81', 0, 8, 3, 3),
item('panel-95', 0, 11, 6, 9),
item('panel-96', 6, 11, 6, 9),
item('panel-97', 12, 11, 12, 9),
item('panel-80', 0, 0, 8, 4),
item('panel-81', 8, 0, 8, 4),
item('panel-82', 16, 0, 8, 4),
]),
row('Product Analytics', []),
// Row 3: HTTP — full-width error breakdown; traffic ranking and latency moved
// into the dense Service Health screen above.
// Row 3: Product Analytics — Prom-safe event volume and server TTS health.
// Distinct-user analytics stay in Postgres `product_events`; this row
// intentionally never uses user_id/session/request labels.
row('Product Analytics', [
item('panel-95', 0, 0, 6, 5),
item('panel-96', 6, 0, 6, 5),
item('panel-99', 12, 0, 6, 5),
item('panel-100', 18, 0, 6, 5),
item('panel-97', 0, 5, 12, 8),
item('panel-98', 12, 5, 12, 4),
item('panel-101', 12, 9, 12, 4),
item('panel-102', 0, 13, 12, 5),
item('panel-103', 12, 13, 12, 5),
]),
// Row 3: HTTP — full-width error breakdown on top, then traffic ranking +
// latency trend side by side.
row('HTTP', [
item('panel-94', 0, 0, 24, 8),
item('panel-16', 0, 8, 7, 11),
item('panel-20', 7, 8, 17, 11),
]),
// Row 4: LLM gateway — request mix + latency side by side.
row('LLM Gateway', [
item('panel-11', 0, 0, 12, 8),
item('panel-21', 12, 0, 12, 8),
]),
// Row 5: Provider Upstreams — per-provider rollup so the vendor consoles
// don't have to be opened one by one. Four wide, one screen line.
row('Provider Upstreams', [
item('panel-66', 0, 0, 6, 7),
item('panel-67', 6, 0, 6, 7),
item('panel-68', 12, 0, 6, 7),
item('panel-69', 18, 0, 6, 7),
]),
// Row 4: token totals + throughput + the two revenue/quality alert stats.
row('LLM Tokens & Quality', [
item('panel-43', 0, 0, 6, 7),
item('panel-41', 6, 0, 6, 7),
item('panel-73', 0, 0, 6, 7),
item('panel-71', 6, 0, 10, 7),
item('panel-43', 16, 0, 4, 7),
item('panel-41', 20, 0, 4, 7),
]),
// Row 5: router health — three "wake someone up" stats/gauge + upstream errors.
row('LLM Router Health', [
@ -1036,10 +1026,17 @@ const rows = [
item('panel-31', 8, 0, 8, 7),
item('panel-32', 16, 0, 8, 7),
]),
// Row 8: focused error logs; the live application firehose is docked in
// Service Health for the latest cloud layout.
// Row 7: infra (collapsed) — by-instance breakdowns catch single-replica issues.
row('Infrastructure', [
item('panel-50', 0, 0, 6, 6),
item('panel-51', 6, 0, 6, 6),
item('panel-52', 12, 0, 6, 6),
item('panel-53', 18, 0, 6, 6),
], { collapse: true }),
// Row 8: full-width logs — errors on top (triage focus), firehose below.
row('Logs', [
item('panel-91', 0, 0, 24, 8),
item('panel-91', 0, 0, 24, 10),
item('panel-90', 0, 10, 24, 10),
]),
]
@ -1108,15 +1105,18 @@ const variables = [
* AIRI Server Overview dashboard.
*
* Reading order:
* 1. Service Health dense operations screen with request, LLM, provider,
* token, WebSocket, status, and live application-log signals.
* 2. User Engagement rolling DAU/WAU/MAU, total users, sessions, product
* event health, and the hand-tuned daily-active-user trend.
* 3. HTTP full-width route error breakdown.
* 4. LLM Tokens & Quality revenue-leak and stream-interruption alerts.
* 5. LLM Router Health key/decrypt/fallback "wake someone up" signals.
* 6. Business Stripe / Flux money flow.
* 7. Logs Loki warning/error logs for live debugging.
* 1. Service Health signup/sessions/WS counts, req-rate, 5xx, status-code
* heatmap, live WS trend: "is anything broken right now?"
* 2. User Engagement rolling DAU/WAU/MAU from user.last_seen_at
* 3. Product Analytics Prom-safe product event volume + server TTS health
* 4. HTTP error breakdown by route, request ranking, latency by route
* 5. LLM Gateway per-model request rate + latency (TTFB + end-to-end)
* 6. Provider Upstreams per-provider rate/latency/failure + TTS chars
* 7. LLM Tokens & Quality token totals/throughput, revenue-leak alerts
* 8. LLM Router Health key/decrypt/fallback "wake someone up" signals
* 9. Business Stripe / Flux money flow
* 10. Infrastructure (collapsed) DB / runtime health for triage
* 11. Logs Loki for live debugging
*
* One metric, one panel: we deliberately do not duplicate a metric across
* stat/trend/bar/pie forms. Counter conventions: rate() for "now" trends,
@ -1154,10 +1154,10 @@ export const dashboard = {
preload: false,
tags: ['airi', 'observability', 'grafana-cloud'],
timeSettings: {
autoRefresh: '',
autoRefresh: '30s',
autoRefreshIntervals: ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d'],
fiscalYearStartMonth: 0,
from: 'now-6h',
from: 'now-1h',
hideTimepicker: false,
timezone: 'browser',
to: 'now',

View file

@ -1,7 +1,7 @@
{
"name": "@proj-airi/server",
"type": "module",
"version": "0.11.1",
"version": "0.11.0",
"private": true,
"scripts": {
"apply:env": "dotenvx run -f .env.local --overload --ignore=MISSING_ENV_FILE",
@ -61,7 +61,6 @@
"nanoid": "catalog:",
"ofetch": "catalog:",
"pg": "catalog:",
"posthog-node": "catalog:",
"resend": "catalog:",
"stripe": "catalog:",
"unspeech": "catalog:xsai",

View file

@ -55,7 +55,6 @@ function createTestDeps() {
providerCatalogService: {} as any,
productEventService: {
track: vi.fn(async () => undefined),
trackGeneration: vi.fn(async () => undefined),
countDistinctUsersByFeature: vi.fn(async () => []),
},
configKV: {

View file

@ -75,7 +75,6 @@ import { createStripeRoutes } from './routes/stripe'
import { createVoicePackRoutes } from './routes/voice-packs'
import { createConfigKVService } from './services/adapters/config-kv'
import { createEmailService } from './services/adapters/email'
import { createPosthogSink } from './services/adapters/posthog'
import { createAdminFluxGrantsService } from './services/domain/admin/flux-grants'
import { createAdminRouterConfigService } from './services/domain/admin/router-config'
import { createAdminUsersService } from './services/domain/admin/users'
@ -594,27 +593,9 @@ export async function createApp() {
}, undefined, dependsOn.otel?.email),
})
const posthogSink = injeca.provide('services:posthogSink', {
dependsOn: { env: parsedEnv, lifecycle },
// POSTHOG_PROJECT_KEY defaults to the shared project key, so the falsy
// branch is only reachable via the documented off-switch: setting the
// env var to an empty string (valibot defaults don't apply to '').
build: ({ dependsOn }) => {
if (!dependsOn.env.POSTHOG_PROJECT_KEY)
return null
const sink = createPosthogSink({
projectKey: dependsOn.env.POSTHOG_PROJECT_KEY,
host: dependsOn.env.POSTHOG_API_HOST,
})
dependsOn.lifecycle.appHooks.onStop(() => sink.shutdown())
return sink
},
})
const productEventService = injeca.provide('services:productEvents', {
dependsOn: { db, otel, posthogSink },
build: ({ dependsOn }) => createProductEventService(dependsOn.db, dependsOn.otel?.product, dependsOn.posthogSink),
dependsOn: { db, otel },
build: ({ dependsOn }) => createProductEventService(dependsOn.db, dependsOn.otel?.product),
})
const characterService = injeca.provide('services:characters', {

View file

@ -175,14 +175,6 @@ const EnvSchema = object({
DB_POOL_CONNECTION_TIMEOUT_MS: optionalIntegerFromString(5000, 'DB_POOL_CONNECTION_TIMEOUT_MS', 1),
DB_POOL_KEEPALIVE_INITIAL_DELAY_MS: optionalIntegerFromString(10000, 'DB_POOL_KEEPALIVE_INITIAL_DELAY_MS', 1),
// PostHog product-event forwarding (signup / payment / subscription facts).
// Defaults to the shared AIRI project key (same browser-safe phc_* key the
// client surfaces embed in posthog.config.ts), so forwarding is on out of
// the box. Set to an empty string to disable; Postgres `product_events`
// stays the source of truth either way.
POSTHOG_PROJECT_KEY: optional(string(), 'phc_pzjziJjrVZpa9SqnQqq0QEKvkmuCPH7GDTA6TbRTEf9'), // cspell:disable-line
POSTHOG_API_HOST: optional(string(), 'https://t.airi.build'),
// OpenTelemetry
OTEL_SERVICE_NAMESPACE: optional(string(), 'airi'),
OTEL_SERVICE_NAME: optional(string(), 'server'),

View file

@ -49,7 +49,6 @@ function createService() {
function createProductEventService(): ProductEventService {
return {
track: vi.fn(async () => undefined),
trackGeneration: vi.fn(async () => undefined),
countDistinctUsersByFeature: vi.fn(async () => []),
}
}

View file

@ -186,7 +186,6 @@ function makeFakeDeps(overrides: {
}
const productEventService = {
track: vi.fn(async () => undefined),
trackGeneration: vi.fn(async () => undefined),
countDistinctUsersByFeature: vi.fn(async () => []),
}
const configKV = {

View file

@ -1,21 +0,0 @@
import type { AiGenerationAppSurface } from '../../../services/domain/product-events'
export const AIRI_CHAT_SESSION_ID_HEADER = 'x-airi-session-id'
export const AIRI_CHAT_ROUND_ID_HEADER = 'x-airi-round-id'
export const AIRI_CHAT_APP_SURFACE_HEADER = 'x-airi-app-surface'
const CLIENT_CHAT_ANALYTICS_SURFACES = new Set<AiGenerationAppSurface>(['web', 'mobile', 'electron'])
/**
* Resolves the product runtime from a trusted client hint.
*
* Unknown values are not coerced to `server`: `$ai_generation` uses
* `capture_surface` for the process that emitted the event, while
* `app_surface` stays reserved for the user's actual product runtime.
*/
export function resolveChatAnalyticsSurface(value: string | undefined): AiGenerationAppSurface | undefined {
if (CLIENT_CHAT_ANALYTICS_SURFACES.has(value as AiGenerationAppSurface))
return value as AiGenerationAppSurface
return undefined
}

View file

@ -5,12 +5,6 @@ import type { LlmTracingDeps, V1RouteDeps } from './types'
import { authGuard } from '../../../middlewares/auth'
import { configGuard } from '../../../middlewares/config-guard'
import {
AIRI_CHAT_APP_SURFACE_HEADER,
AIRI_CHAT_ROUND_ID_HEADER,
AIRI_CHAT_SESSION_ID_HEADER,
resolveChatAnalyticsSurface,
} from './analytics'
import { createV1Gateway } from './gateway'
import { chatCompletionsRateLimit } from './middlewares'
import { chatCompletions } from './operations/chat-completions'
@ -47,9 +41,7 @@ export function createV1Routes(input: CreateV1RoutesDeps) {
return {
userId: user.id,
body,
sessionId: c.req.header(AIRI_CHAT_SESSION_ID_HEADER),
roundId: c.req.header(AIRI_CHAT_ROUND_ID_HEADER),
appSurface: resolveChatAnalyticsSurface(c.req.header(AIRI_CHAT_APP_SURFACE_HEADER)),
sessionId: c.req.header('x-airi-session-id'),
abortSignal: c.req.raw.signal,
}
},
@ -74,7 +66,7 @@ export function createV1Routes(input: CreateV1RoutesDeps) {
return {
userId: user.id,
body,
sessionId: c.req.header(AIRI_CHAT_SESSION_ID_HEADER),
sessionId: c.req.header('x-airi-session-id'),
abortSignal: c.req.raw.signal,
}
},

View file

@ -1,6 +1,5 @@
import type { CapabilityAliasRoute } from '../../../../../schemas/provider-catalog'
import type { UsageInfo } from '../../../../../services/domain/billing/billing'
import type { AiGenerationAppSurface } from '../../../../../services/domain/product-events'
import type { GatewayCallback } from '../../gateway'
import type { V1RouteDeps } from '../../types'
@ -21,25 +20,9 @@ export interface ChatCompletionsOperationRequest {
userId: string
body: Record<string, unknown>
sessionId?: string
roundId?: string
appSurface?: AiGenerationAppSurface
abortSignal?: AbortSignal
}
interface GenerationCaptureInput {
deps: V1RouteDeps
userId: string
requestId: string
sessionId?: string
roundId?: string
appSurface?: AiGenerationAppSurface
generationModel: string
routeCtxProvider: string
usage: UsageInfo
durationMs: number
stream: boolean
}
export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.completions'> {
const logger = useLogger('v1-completions').useGlobalConfig()
const telemetry = createRouteTelemetry({
@ -199,11 +182,7 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple
durationMs,
requestId,
userId: input.userId,
sessionId: input.sessionId,
roundId: input.roundId,
appSurface: input.appSurface,
requestModel,
generationModel: langfuseModel,
routeCtxProvider: routeCtx.provider,
billing,
billingPolicy,
@ -220,11 +199,7 @@ export function chatCompletions(deps: V1RouteDeps): GatewayCallback<'chat.comple
durationMs,
requestId,
userId: input.userId,
sessionId: input.sessionId,
roundId: input.roundId,
appSurface: input.appSurface,
requestModel,
generationModel: langfuseModel,
routeCtxProvider: routeCtx.provider,
billing,
billingPolicy,
@ -238,37 +213,6 @@ interface ChatModelAliasPlan {
modelIds: string[]
}
function captureGeneration(input: GenerationCaptureInput): void {
const generationId = input.roundId ?? input.requestId
const conversationId = input.sessionId ?? input.requestId
const totalTokens = input.usage.promptTokens != null && input.usage.completionTokens != null
? input.usage.promptTokens + input.usage.completionTokens
: undefined
input.deps.productEventService.trackGeneration({
userId: input.userId,
traceId: conversationId,
generationId,
model: input.generationModel,
provider: input.routeCtxProvider || 'unknown',
providerType: 'official',
usageSource: input.usage.promptTokens != null || input.usage.completionTokens != null
? 'reported'
: 'unavailable',
inputTokens: input.usage.promptTokens,
outputTokens: input.usage.completionTokens,
totalTokens,
costUsdSource: 'unavailable',
conversationId,
conversationIdSource: input.sessionId ? 'client_header' : 'server_request',
roundId: generationId,
...(input.appSurface && { appSurface: input.appSurface }),
captureSurface: 'server',
latencySeconds: input.durationMs / 1000,
stream: input.stream,
})
}
async function resolveChatModelAliasPlan(deps: V1RouteDeps, aliasId: string): Promise<ChatModelAliasPlan> {
const alias = await deps.providerCatalogService.resolveEnabledAlias('llm', aliasId)
const primaryRoutes = alias.routes.filter(route => route.pool === 'primary')
@ -358,11 +302,7 @@ function streamChatCompletion(input: {
durationMs: number
requestId: string
userId: string
sessionId?: string
roundId?: string
appSurface?: AiGenerationAppSurface
requestModel: string
generationModel: string
routeCtxProvider: string
billing: ChatBilling
billingPolicy: ChatBillingPolicy
@ -481,20 +421,6 @@ function streamChatCompletion(input: {
})
input.telemetry.recordMetrics({ model: input.requestModel, status: input.response.status, type: 'chat', provider: input.routeCtxProvider, durationMs: input.durationMs, fluxConsumed, ...usage })
captureGeneration({
deps: input.deps,
userId: input.userId,
requestId: input.requestId,
sessionId: input.sessionId,
roundId: input.roundId,
appSurface: input.appSurface,
generationModel: input.generationModel,
routeCtxProvider: input.routeCtxProvider,
usage,
durationMs: input.durationMs,
stream: true,
})
// Debit flux via DB transaction (source of truth)
// NOTICE: streaming response is already sent, so we cannot reject on failure.
// Log at error level so unpaid usage is visible in monitoring/alerts.
@ -581,11 +507,7 @@ async function completeNonStreamingChat(input: {
durationMs: number
requestId: string
userId: string
sessionId?: string
roundId?: string
appSurface?: AiGenerationAppSurface
requestModel: string
generationModel: string
routeCtxProvider: string
billing: ChatBilling
billingPolicy: ChatBillingPolicy
@ -634,20 +556,6 @@ async function completeNonStreamingChat(input: {
})
input.telemetry.recordMetrics({ model: input.requestModel, status: input.response.status, type: 'chat', provider: input.routeCtxProvider, durationMs: input.durationMs, fluxConsumed, ...usage })
captureGeneration({
deps: input.deps,
userId: input.userId,
requestId: input.requestId,
sessionId: input.sessionId,
roundId: input.roundId,
appSurface: input.appSurface,
generationModel: input.generationModel,
routeCtxProvider: input.routeCtxProvider,
usage,
durationMs: input.durationMs,
stream: false,
})
// Debit flux via DB transaction (source of truth).
// The upstream call has already happened (cost incurred), so partial
// debit + `fluxUnbilled` is the only sane recovery — same shape as the

View file

@ -14,11 +14,6 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { createV1Routes } from '.'
import { ApiError } from '../../../utils/error'
import {
AIRI_CHAT_APP_SURFACE_HEADER,
AIRI_CHAT_ROUND_ID_HEADER,
AIRI_CHAT_SESSION_ID_HEADER,
} from './analytics'
function createMockFluxService(flux = 100): FluxService {
return {
@ -148,7 +143,6 @@ function createMockLlmRouter(impl?: Partial<LlmRouterService>): LlmRouterService
function createMockProductEventService(): ProductEventService {
return {
track: vi.fn(async () => undefined),
trackGeneration: vi.fn(async () => undefined),
countDistinctUsersByFeature: vi.fn(async () => []),
}
}
@ -913,7 +907,7 @@ describe('v1CompletionsRoutes', () => {
}
})
it('records Langfuse and PostHog generations with authoritative usage and correlation', async () => {
it('records Langfuse chat generation with the router-resolved upstream model', async () => {
const llmRouter = createMockLlmRouter({
route: vi.fn(async (_req, ctx) => {
if (ctx) {
@ -930,27 +924,12 @@ describe('v1CompletionsRoutes', () => {
}) as any,
})
const llmTracing = createMockLlmTracing()
const productEventService = createMockProductEventService()
const app = createTestApp(
createMockFluxService(),
createMockConfigKV(),
undefined,
undefined,
undefined,
llmRouter,
llmTracing,
productEventService,
)
const app = createTestApp(createMockFluxService(), createMockConfigKV(), undefined, undefined, undefined, llmRouter, llmTracing)
await app.fetch(
new Request('http://localhost/api/v1/openai/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
[AIRI_CHAT_SESSION_ID_HEADER]: 'conversation-1',
[AIRI_CHAT_ROUND_ID_HEADER]: 'round-1',
[AIRI_CHAT_APP_SURFACE_HEADER]: 'electron',
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'chat-auto', messages: [{ role: 'user', content: 'hi' }] }),
}),
{ user: testUser } as any,
@ -963,88 +942,6 @@ describe('v1CompletionsRoutes', () => {
userId: 'user-1',
}),
)
expect(productEventService.trackGeneration).toHaveBeenCalledWith({
userId: 'user-1',
traceId: 'conversation-1',
generationId: 'round-1',
model: 'openai/gpt-4o-mini',
provider: 'openrouter',
providerType: 'official',
usageSource: 'reported',
inputTokens: 1,
outputTokens: 2,
totalTokens: 3,
costUsdSource: 'unavailable',
conversationId: 'conversation-1',
conversationIdSource: 'client_header',
roundId: 'round-1',
appSurface: 'electron',
captureSurface: 'server',
latencySeconds: expect.any(Number),
stream: false,
})
})
it('uses request-level correlation for server-captured generations without chat headers', async () => {
const llmRouter = createMockLlmRouter({
route: vi.fn(async (_req, ctx) => {
if (ctx) {
ctx.provider = 'openrouter'
ctx.upstreamModel = 'openai/gpt-4o-mini'
}
return new Response(JSON.stringify({
choices: [],
usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as any,
})
const productEventService = createMockProductEventService()
const app = createTestApp(
createMockFluxService(),
createMockConfigKV(),
undefined,
undefined,
undefined,
llmRouter,
createMockLlmTracing(),
productEventService,
)
await app.fetch(
new Request('http://localhost/api/v1/openai/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'chat-auto', messages: [{ role: 'user', content: 'hi' }] }),
}),
{ user: testUser } as any,
)
expect(productEventService.trackGeneration).toHaveBeenCalledWith({
userId: 'user-1',
traceId: expect.any(String),
generationId: expect.any(String),
model: 'openai/gpt-4o-mini',
provider: 'openrouter',
providerType: 'official',
usageSource: 'reported',
inputTokens: 1,
outputTokens: 2,
totalTokens: 3,
costUsdSource: 'unavailable',
conversationId: expect.any(String),
conversationIdSource: 'server_request',
roundId: expect.any(String),
captureSurface: 'server',
latencySeconds: expect.any(Number),
stream: false,
})
const generation = vi.mocked(productEventService.trackGeneration).mock.calls[0]?.[0]
expect(generation?.traceId).toBe(generation?.conversationId)
expect(generation?.roundId).toBe(generation?.generationId)
expect(generation).not.toHaveProperty('appSurface')
})
it('should not charge flux when upstream returns error', async () => {

View file

@ -33,11 +33,6 @@ export interface CheckoutOperationInput {
request: Request
}
interface PosthogIdentityHeaders {
distinctId?: string
sessionId?: string
}
/**
* Creates Stripe checkout sessions for Flux packages.
*
@ -80,7 +75,6 @@ export function createCheckoutOperation(deps: CheckoutOperationDeps) {
const paymentMethods = await deps.configKV.getOptional('STRIPE_PAYMENT_METHODS')
const paymentMethodOptions = await deps.configKV.getOptional('STRIPE_PAYMENT_METHOD_OPTIONS') ?? {}
const posthogIdentity = readPosthogIdentityHeaders(input.request)
const sessionParams: CheckoutSessionCreateParams = {
line_items: [{ price: stripePriceId, quantity: 1 }],
@ -93,8 +87,6 @@ export function createCheckoutOperation(deps: CheckoutOperationDeps) {
metadata: {
userId: input.user.id,
fluxAmount: String(fluxAmount),
...(posthogIdentity.distinctId && { posthogDistinctId: posthogIdentity.distinctId }),
...(posthogIdentity.sessionId && { posthogSessionId: posthogIdentity.sessionId }),
},
}
@ -141,31 +133,9 @@ export function createCheckoutOperation(deps: CheckoutOperationDeps) {
flux_amount: fluxAmount,
amount_total: session.amount_total,
currency: session.currency,
...(posthogIdentity.distinctId && { posthog_distinct_id: posthogIdentity.distinctId }),
...(posthogIdentity.sessionId && { posthog_session_id: posthogIdentity.sessionId }),
},
})
return { url: session.url }
}
}
function readPosthogIdentityHeaders(request: Request): PosthogIdentityHeaders {
const distinctId = readStripeMetadataHeader(request, 'x-posthog-distinct-id')
const sessionId = readStripeMetadataHeader(request, 'x-posthog-session-id')
return {
...(distinctId && { distinctId }),
...(sessionId && { sessionId }),
}
}
function readStripeMetadataHeader(request: Request, name: string): string | undefined {
const value = request.headers.get(name)?.trim()
if (!value)
return undefined
// Stripe metadata values are capped and user-controlled headers can be
// oversized. Truncating keeps the checkout request valid without turning
// analytics identity into a payment blocker.
return value.slice(0, 200)
}

View file

@ -89,8 +89,6 @@ export function createWebhookOperation(deps: WebhookOperationDeps) {
const userId = event.data.object.metadata?.userId
if (userId) {
const fluxAmount = Number(event.data.object.metadata?.fluxAmount)
const posthogDistinctId = event.data.object.metadata?.posthogDistinctId
const posthogSessionId = event.data.object.metadata?.posthogSessionId
void deps.productEventService?.track({
userId,
feature: 'billing',
@ -101,10 +99,6 @@ export function createWebhookOperation(deps: WebhookOperationDeps) {
amount_total: event.data.object.amount_total,
currency: event.data.object.currency,
flux_amount: Number.isFinite(fluxAmount) ? fluxAmount : null,
stripe_checkout_session_id: event.data.object.id,
stripe_customer_id: typeof event.data.object.customer === 'string' ? event.data.object.customer : event.data.object.customer?.id ?? null,
...(posthogDistinctId && { posthog_distinct_id: posthogDistinctId }),
...(posthogSessionId && { posthog_session_id: posthogSessionId }),
},
})
}

View file

@ -10,7 +10,6 @@ import { describe, expect, it, vi } from 'vitest'
import { createStripeRoutes, formatPrice } from '.'
import { ApiError } from '../../utils/error'
import { createCheckoutOperation } from './operations/checkout'
import { createWebhookOperation } from './operations/webhook'
// --- Mock helpers ---
@ -303,77 +302,6 @@ describe('stripeRoutes', () => {
)
expect(res.status).toBe(503)
})
it('stores browser PostHog identity in Stripe checkout metadata', async () => {
const createSession = vi.fn(async input => ({
id: 'cs_1',
url: 'https://checkout.stripe.com/cs_1',
customer: null,
mode: 'payment',
status: 'open',
payment_status: 'unpaid',
amount_total: 500,
currency: 'usd',
success_url: 'http://localhost/settings/flux?success=true',
cancel_url: 'http://localhost/settings/flux?canceled=true',
payment_intent: null,
subscription: null,
metadata: input.metadata,
expires_at: null,
}))
const productEventService = { track: vi.fn() }
const operation = createCheckoutOperation({
stripe: {
checkout: {
sessions: {
create: createSession,
},
},
} as any,
priceCatalog: {
findActivePrice: vi.fn(async () => ({
id: 'price_test_500',
currency: 'usd',
unitAmount: 500,
currencyOptions: {},
metadata: { fluxAmount: '500' },
})),
getActivePrices: vi.fn(),
} as any,
stripeService: createMockStripeService(),
configKV: createMockConfigKV({ STRIPE_PAYMENT_METHODS: undefined }),
env: testEnv,
productEventService: productEventService as any,
})
await operation({
user: testUser as any,
body: { stripePriceId: 'price_test_500' },
request: new Request('http://localhost/api/v1/stripe/checkout', {
headers: {
'x-posthog-distinct-id': 'anon-browser-1',
'x-posthog-session-id': 'ph-session-1',
},
}),
})
expect(createSession).toHaveBeenCalledWith(expect.objectContaining({
metadata: {
userId: 'user-1',
fluxAmount: '500',
posthogDistinctId: 'anon-browser-1',
posthogSessionId: 'ph-session-1',
},
}))
expect(productEventService.track).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1',
action: 'checkout_started',
metadata: expect.objectContaining({
posthog_distinct_id: 'anon-browser-1',
posthog_session_id: 'ph-session-1',
}),
}))
})
})
describe('gET /api/v1/stripe/orders', () => {
@ -543,75 +471,6 @@ describe('stripeRoutes', () => {
expect(res.status).toBe(503)
})
it('records payment completion with Stripe and PostHog identity from checkout metadata', async () => {
const checkoutEvent = {
id: 'evt_checkout_completed',
type: 'checkout.session.completed',
data: {
object: {
id: 'cs_1',
customer: 'cus_1',
customer_email: 'test@example.com',
mode: 'payment',
status: 'complete',
payment_status: 'paid',
amount_total: 500,
currency: 'usd',
success_url: 'http://localhost/settings/flux?success=true',
cancel_url: 'http://localhost/settings/flux?canceled=true',
payment_intent: 'pi_1',
subscription: null,
metadata: {
userId: 'user-1',
fluxAmount: '500',
posthogDistinctId: 'anon-browser-1',
posthogSessionId: 'ph-session-1',
},
expires_at: null,
},
},
}
const productEventService = { track: vi.fn() }
const billingService = createMockBillingService()
const webhook = createWebhookOperation({
stripe: {
webhooks: {
constructEvent: vi.fn(() => checkoutEvent),
},
} as any,
webhookSecret: 'whsec_test',
fluxService: createMockFluxService(),
stripeService: createMockStripeService(),
billingService,
productEventService: productEventService as any,
})
await webhook({ signature: 'test_sig', body: '{}' })
expect(billingService.creditFluxFromStripeCheckout).toHaveBeenCalledWith(expect.objectContaining({
stripeEventId: 'evt_checkout_completed',
userId: 'user-1',
stripeSessionId: 'cs_1',
fluxAmount: 500,
}))
expect(productEventService.track).toHaveBeenCalledWith({
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
source: 'stripe.webhook',
metadata: {
amount_total: 500,
currency: 'usd',
flux_amount: 500,
stripe_checkout_session_id: 'cs_1',
stripe_customer_id: 'cus_1',
posthog_distinct_id: 'anon-browser-1',
posthog_session_id: 'ph-session-1',
},
})
})
it('records subscription lifecycle product events from Stripe webhooks', async () => {
const subscriptionEvent = {
id: 'evt_sub_created',

View file

@ -1,78 +0,0 @@
import { useLogger } from '@guiiai/logg'
import { PostHog } from 'posthog-node'
const logger = useLogger('posthog')
/**
* One product event forwarded to PostHog, keyed by the Better Auth user id
* so it merges with the browser person identified via `posthog.identify()`.
*/
export interface PosthogCaptureInput {
distinctId: string
event: string
properties: Record<string, unknown>
}
/**
* Minimal capture boundary the product-events service depends on. Kept as
* an interface so tests inject a fake instead of mocking the SDK.
*/
export interface PosthogSink {
/**
* Queue a high-volume analytics event without waiting for a network
* roundtrip. Use on request hot paths where occasional process-exit loss is
* preferable to user-visible latency.
*/
captureQueued?: (input: PosthogCaptureInput) => void
capture: (input: PosthogCaptureInput) => Promise<void>
/** Flush and close the underlying client. Call on server shutdown. */
shutdown: () => Promise<void>
}
/**
* PostHog sink for server-side product events.
*
* Low-frequency conversion facts use `captureImmediate` (one HTTP roundtrip
* per event) because they terminate money/auth funnels. High-frequency AI
* generation facts use `captureQueued`, which is buffered by the SDK and
* flushed on shutdown, so chat completion requests don't wait on PostHog.
*
* Capture failures are logged and swallowed analytics forwarding must
* never fail the Stripe webhook or auth flow that triggered it. The
* Postgres `product_events` row is the source of truth either way.
*/
export function createPosthogSink(options: { projectKey: string, host: string }): PosthogSink {
const client = new PostHog(options.projectKey, { host: options.host })
return {
captureQueued(input: PosthogCaptureInput): void {
try {
client.capture({
distinctId: input.distinctId,
event: input.event,
properties: input.properties,
})
}
catch (err) {
logger.withError(err).withFields({ event: input.event }).warn('Failed to enqueue product event to PostHog')
}
},
async capture(input: PosthogCaptureInput): Promise<void> {
try {
await client.captureImmediate({
distinctId: input.distinctId,
event: input.event,
properties: input.properties,
})
}
catch (err) {
logger.withError(err).withFields({ event: input.event }).warn('Failed to forward product event to PostHog')
}
},
async shutdown(): Promise<void> {
await client.shutdown()
},
}
}

View file

@ -1,7 +1,6 @@
import type { Database } from '../../libs/db'
import type { ProductMetrics } from '../../otel'
import { sql } from 'drizzle-orm'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mockDB } from '../../libs/mock-db'
@ -137,239 +136,4 @@ describe('productEventService', () => {
flux_balance_bucket: 'zero',
})
})
it('forwards allowlisted business facts to PostHog keyed by user id, mapping user_signed_up to signup_completed', async () => {
const capture = vi.fn(async () => {})
const sink = { capture, shutdown: vi.fn(async () => {}) }
const service = createProductEventService(db, null, sink)
await service.track({
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
source: 'stripe.webhook',
metadata: { amount_minor_unit: 990, currency: 'usd' },
})
await service.track({
userId: 'user-2',
feature: 'auth',
action: 'user_signed_up',
status: 'succeeded',
})
expect(capture).toHaveBeenNthCalledWith(1, {
distinctId: 'user-1',
event: 'payment_completed',
properties: {
app_surface: 'server',
airi_user_id: 'user-1',
feature: 'billing',
status: 'succeeded',
source: 'stripe.webhook',
amount_minor_unit: 990,
currency: 'usd',
},
})
expect(capture).toHaveBeenNthCalledWith(2, {
distinctId: 'user-2',
event: 'signup_completed',
properties: {
app_surface: 'server',
airi_user_id: 'user-2',
feature: 'auth',
status: 'succeeded',
},
})
const rows = await db.select().from(schema.productEvents)
expect(rows).toHaveLength(2)
})
it('merges Stripe webhook conversions with the browser PostHog person when a distinct id is present', async () => {
const capture = vi.fn(async () => {})
const sink = { capture, shutdown: vi.fn(async () => {}) }
const service = createProductEventService(db, null, sink)
await service.track({
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
source: 'stripe.webhook',
metadata: {
posthog_distinct_id: 'anon-browser-1',
posthog_session_id: 'ph-session-1',
stripe_checkout_session_id: 'cs_1',
},
})
expect(capture).toHaveBeenNthCalledWith(1, {
distinctId: 'user-1',
event: '$identify',
properties: {
$anon_distinct_id: 'anon-browser-1',
$session_id: 'ph-session-1',
airi_user_id: 'user-1',
},
})
expect(capture).toHaveBeenNthCalledWith(2, {
distinctId: 'user-1',
event: 'payment_completed',
properties: {
app_surface: 'server',
airi_user_id: 'user-1',
posthog_distinct_id: 'anon-browser-1',
$session_id: 'ph-session-1',
feature: 'billing',
status: 'succeeded',
source: 'stripe.webhook',
posthog_session_id: 'ph-session-1',
stripe_checkout_session_id: 'cs_1',
},
})
})
it('does not forward high-volume per-request actions to PostHog', async () => {
const capture = vi.fn(async () => {})
const sink = { capture, shutdown: vi.fn(async () => {}) }
const service = createProductEventService(db, null, sink)
await service.track({
userId: 'user-1',
feature: 'gen_ai_chat',
action: 'completion_succeeded',
status: 'succeeded',
})
await service.track({
userId: 'user-1',
feature: 'billing',
action: 'checkout_started',
status: 'started',
})
expect(capture).not.toHaveBeenCalled()
const rows = await db.select().from(schema.productEvents)
expect(rows).toHaveLength(2)
})
it('captures an LLM generation as a PostHog AI fact without storing prompts or responses', async () => {
const capture = vi.fn(async () => {})
const captureQueued = vi.fn()
const sink = { capture, captureQueued, shutdown: vi.fn(async () => {}) }
const service = createProductEventService(db, null, sink)
service.trackGeneration({
userId: 'user-1',
traceId: 'session-1',
generationId: 'round-1',
model: 'openai/gpt-5-mini',
provider: 'openai',
providerType: 'official',
usageSource: 'reported',
inputTokens: 12,
outputTokens: 8,
totalTokens: 20,
conversationId: 'session-1',
conversationIdSource: 'client_header',
roundId: 'round-1',
appSurface: 'electron',
captureSurface: 'server',
})
expect(capture).not.toHaveBeenCalled()
expect(captureQueued).toHaveBeenCalledWith({
distinctId: 'user-1',
event: '$ai_generation',
properties: {
$ai_trace_id: 'session-1',
$ai_session_id: 'session-1',
$ai_span_id: 'round-1',
$ai_model: 'openai/gpt-5-mini',
$ai_provider: 'openai',
$ai_input_tokens: 12,
$ai_output_tokens: 8,
$ai_total_tokens: 20,
$insert_id: 'ai-generation:round-1',
airi_user_id: 'user-1',
provider_type: 'official',
usage_source: 'reported',
token_usage_available: true,
cost_usd_source: 'unavailable',
cost_usd_known: false,
conversation_id: 'session-1',
conversation_id_source: 'client_header',
round_id: 'round-1',
app_surface: 'electron',
capture_surface: 'server',
},
})
const rows = await db.select().from(schema.productEvents)
expect(rows).toHaveLength(0)
})
it('does not forward to PostHog when the DB write fails', async () => {
// ROOT CAUSE:
//
// track() swallows DB insert errors to protect the caller, but the
// PostHog forwarding block ran unconditionally afterwards — a Postgres
// outage during a Stripe webhook would mint `payment_completed` in
// PostHog with no `product_events` row backing it, breaking the
// "Postgres is the fact of record" invariant and later reconciliation.
// Found by PR #2038 review.
//
// Fixed by gating forwarding on a `persisted` flag set only after the
// insert resolves.
const capture = vi.fn(async () => {})
const sink = { capture, shutdown: vi.fn(async () => {}) }
const service = createProductEventService(db, null, sink)
// Simulate a DB outage by renaming the table out from under the insert.
await db.execute(sql`ALTER TABLE product_events RENAME TO product_events_outage`)
try {
await expect(service.track({
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
})).resolves.toBeUndefined()
}
finally {
await db.execute(sql`ALTER TABLE product_events_outage RENAME TO product_events`)
}
expect(capture).not.toHaveBeenCalled()
const rows = await db.select().from(schema.productEvents)
expect(rows).toHaveLength(0)
})
it('persists the product event even when a misbehaving sink throws', async () => {
// ROOT CAUSE:
//
// The PosthogSink contract says implementations swallow transport
// errors, but the forwarding call sits on the Stripe webhook path —
// if a sink ever throws, an unguarded `await` would fail the webhook
// after the fact was already persisted, causing Stripe to retry and
// (before the idempotency guard) double-process the payment.
//
// track() therefore wraps forwarding in its own try/catch: the DB row
// must survive and track() must resolve regardless of sink behavior.
const capture = vi.fn(async () => {
throw new Error('posthog exploded')
})
const sink = { capture, shutdown: vi.fn(async () => {}) }
const service = createProductEventService(db, null, sink)
await expect(service.track({
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
})).resolves.toBeUndefined()
const rows = await db.select().from(schema.productEvents)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ action: 'payment_completed', status: 'succeeded' })
})
})

View file

@ -1,7 +1,6 @@
import type { Database } from '../../libs/db'
import type { ProductMetrics } from '../../otel'
import type { ProductEventMetadata } from '../../schemas/product-events'
import type { PosthogSink } from '../adapters/posthog'
import { useLogger } from '@guiiai/logg'
import { and, asc, count, gte, lt, sql } from 'drizzle-orm'
@ -76,63 +75,6 @@ export interface ProductEventAggregateRow {
distinctUsers: number
}
/** Product runtime where the user initiated the AI generation. */
export type AiGenerationAppSurface = 'web' | 'mobile' | 'electron'
/** Runtime that captured the `$ai_generation` fact. */
export type AiGenerationCaptureSurface = 'server' | 'client'
/** Explains whether `conversation_id` is an app conversation or a server fallback. */
export type AiGenerationConversationIdSource = 'client_header' | 'server_request'
/** Explains whether AIRI supplied a trustworthy USD cost for this generation. */
export type AiGenerationCostUsdSource = 'reported' | 'estimated' | 'unavailable'
/** Content-free PostHog AI generation fact keyed to the authenticated user. */
export interface AiGenerationEventInput {
userId: string
traceId: string
generationId: string
model: string
provider: string
providerType: 'official' | 'custom' | 'unknown'
usageSource: 'reported' | 'estimated' | 'unavailable'
inputTokens?: number
outputTokens?: number
totalTokens?: number
totalCostUsd?: number
costUsdSource?: AiGenerationCostUsdSource
/** Always present for joins; `conversationIdSource` tells whether it is request-level fallback. */
conversationId: string
/** Distinguishes real client conversation ids from server-generated request fallbacks. */
conversationIdSource: AiGenerationConversationIdSource
roundId?: string
/** Omitted when the server cannot determine the user's product runtime. */
appSurface?: AiGenerationAppSurface
/** Defaults to `server` because this service runs in the API process. */
captureSurface?: AiGenerationCaptureSurface
latencySeconds?: number
stream?: boolean
}
/**
* Server-side actions worth a PostHog copy, mapped to the event name the
* client-side funnels expect. Only business facts that terminate or anchor
* a funnel are forwarded per-request LLM/TTS volume stays in Postgres and
* Grafana where it belongs (see `docs/ai-context/metrics-ownership.md`).
*
* `user_signed_up` maps to `signup_completed` because the identified server
* hook is the canonical registration fact for every signup method. Anonymous
* auth UI progress uses `signup_form_completed` and never reuses this name.
*/
const POSTHOG_FORWARDED_ACTIONS: Partial<Record<ProductAction, string>> = {
user_signed_up: 'signup_completed',
payment_completed: 'payment_completed',
subscription_started: 'subscription_started',
subscription_renewed: 'subscription_renewed',
subscription_cancelled: 'subscription_cancelled',
}
/**
* Builds bounded Prometheus labels from product event inputs.
*/
@ -154,11 +96,6 @@ function metricLabels(input: ProductEventInput): Record<string, string> {
return attrs
}
function stringMetadata(input: ProductEventInput, key: string): string | undefined {
const value = input.metadata?.[key]
return typeof value === 'string' && value.length > 0 ? value : undefined
}
/**
* Creates AIRI's first-party product analytics event writer.
*
@ -176,57 +113,9 @@ function stringMetadata(input: ProductEventInput, key: string): string | undefin
* Returns:
* - Best-effort event writer plus a DB aggregation helper for analytics jobs.
*/
export function createProductEventService(db: Database, metrics?: ProductMetrics | null, posthog?: PosthogSink | null) {
export function createProductEventService(db: Database, metrics?: ProductMetrics | null) {
return {
trackGeneration(input: AiGenerationEventInput): void {
if (!posthog)
return
const event = {
distinctId: input.userId,
event: '$ai_generation',
properties: {
$ai_trace_id: input.traceId,
$ai_session_id: input.conversationId,
$ai_span_id: input.generationId,
$ai_model: input.model,
$ai_provider: input.provider,
...(input.inputTokens != null && { $ai_input_tokens: input.inputTokens }),
...(input.outputTokens != null && { $ai_output_tokens: input.outputTokens }),
...(input.totalTokens != null && { $ai_total_tokens: input.totalTokens }),
...(input.totalCostUsd != null && { $ai_total_cost_usd: input.totalCostUsd }),
...(input.latencySeconds != null && { $ai_latency: input.latencySeconds }),
...(input.stream != null && { $ai_stream: input.stream }),
$insert_id: `ai-generation:${input.generationId}`,
airi_user_id: input.userId,
provider_type: input.providerType,
usage_source: input.usageSource,
token_usage_available: input.usageSource !== 'unavailable',
cost_usd_source: input.costUsdSource ?? 'unavailable',
cost_usd_known: input.totalCostUsd != null,
conversation_id: input.conversationId,
conversation_id_source: input.conversationIdSource,
...(input.roundId && { round_id: input.roundId }),
...(input.appSurface && { app_surface: input.appSurface }),
capture_surface: input.captureSurface ?? 'server',
},
}
if (posthog.captureQueued) {
posthog.captureQueued(event)
return
}
void posthog.capture(event)
.catch(err => logger.withError(err).withFields({ generationId: input.generationId }).warn('Failed to capture PostHog AI generation'))
},
async track(input: ProductEventInput): Promise<void> {
// Postgres is the fact of record, so forwarding is gated both ways:
// the DB write comes first (a PostHog outage can't lose the row) and
// forwarding only runs when the row actually landed (a DB outage
// can't mint PostHog events with no DB backing).
let persisted = false
try {
await db.insert(schema.productEvents).values({
userId: input.userId,
@ -240,7 +129,6 @@ export function createProductEventService(db: Database, metrics?: ProductMetrics
metadata: input.metadata,
createdAt: input.createdAt,
})
persisted = true
metrics?.events.add(1, metricLabels(input))
}
@ -252,49 +140,6 @@ export function createProductEventService(db: Database, metrics?: ProductMetrics
status: input.status,
}).warn('Failed to write product event; swallowing to protect caller')
}
// PostHog copy so browser funnels (identified by the same Better Auth
// user id) get their server-side terminator events.
const forwardedEvent = POSTHOG_FORWARDED_ACTIONS[input.action]
if (persisted && posthog && forwardedEvent) {
try {
const posthogDistinctId = stringMetadata(input, 'posthog_distinct_id')
const posthogSessionId = stringMetadata(input, 'posthog_session_id')
if (posthogDistinctId && posthogDistinctId !== input.userId) {
await posthog.capture({
distinctId: input.userId,
event: '$identify',
properties: {
$anon_distinct_id: posthogDistinctId,
airi_user_id: input.userId,
...(posthogSessionId && { $session_id: posthogSessionId }),
},
})
}
await posthog.capture({
distinctId: input.userId,
event: forwardedEvent,
properties: {
app_surface: 'server',
airi_user_id: input.userId,
...(posthogDistinctId && { posthog_distinct_id: posthogDistinctId }),
...(posthogSessionId && { $session_id: posthogSessionId }),
feature: input.feature,
status: input.status,
...(input.source && { source: input.source }),
...(input.reason && { reason: input.reason }),
...input.metadata,
},
})
}
catch (err) {
// The sink contract already swallows transport errors; this guard
// is the last line so a misbehaving sink can never fail the
// webhook/auth flow that produced the business fact.
logger.withError(err).withFields({ action: input.action }).warn('PostHog forwarding threw; product event already persisted')
}
}
},
async countDistinctUsersByFeature(input: ProductEventAggregateInput): Promise<ProductEventAggregateRow[]> {

View file

@ -1,2 +1,2 @@
AIRI_VERSION_NAME=0.11.1
AIRI_VERSION_CODE=14
AIRI_VERSION_NAME=0.11.0
AIRI_VERSION_CODE=13

View file

@ -317,7 +317,7 @@
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 18;
CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = 433DLLA855;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = AIRI;
@ -327,7 +327,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.11.1;
MARKETING_VERSION = 0.11.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = "ai.moeru.airi-pocket";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -345,7 +345,7 @@
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 18;
CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = 433DLLA855;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = AIRI;
@ -355,7 +355,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.11.1;
MARKETING_VERSION = 0.11.0;
PRODUCT_BUNDLE_IDENTIFIER = "ai.moeru.airi-pocket";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "AIRI CI";

View file

@ -1,6 +1,5 @@
<script setup lang="ts">
import { OnboardingDialog, OnboardingStepAnalyticsNotice, ToasterRoot } from '@proj-airi/stage-ui/components'
import { useAuthProviderSync } from '@proj-airi/stage-ui/composables/use-auth-provider-sync'
import { isPosthogAvailableInBuild, useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
@ -21,8 +20,6 @@ import OnboardingPermissionsStep from './components/onboarding/step-permissions.
import { getHostWebSocketConnector } from './modules/websocket-bridge'
useAuthProviderSync()
const contextBridgeStore = useContextBridgeStore()
const i18n = useI18n()
const displayModelsStore = useDisplayModelsStore()

View file

@ -1,7 +1,7 @@
{
"name": "@proj-airi/stage-tamagotchi",
"type": "module",
"version": "0.11.1",
"version": "0.11.0",
"private": true,
"description": "LLM powered virtual character",
"author": {
@ -62,7 +62,6 @@
"@proj-airi/font-cjkfonts-allseto": "workspace:^",
"@proj-airi/font-xiaolai": "workspace:^",
"@proj-airi/i18n": "workspace:^",
"@proj-airi/pipelines-audio": "workspace:^",
"@proj-airi/plugin-sdk-tamagotchi": "workspace:^",
"@proj-airi/server-sdk": "workspace:*",
"@proj-airi/stage-layouts": "workspace:^",

View file

@ -13,7 +13,7 @@ import { electronApp, optimizer } from '@electron-toolkit/utils'
import { Format, LogLevel, setGlobalFormat, setGlobalHookPostLog, setGlobalLogLevel, useLogg } from '@guiiai/logg'
import { createContext } from '@moeru/eventa/adapters/electron/main'
import { initScreenCaptureForMain } from '@proj-airi/electron-screen-capture/main'
import { app, ipcMain, session } from 'electron'
import { app, ipcMain } from 'electron'
import { noop } from 'es-toolkit'
import { createLoggLogger, injeca, lifecycle } from 'injeca'
import { isLinux } from 'std-env'
@ -37,7 +37,6 @@ import { setupExtensionHost } from './services/airi/plugins'
import { setupArtistryBridge } from './services/airi/widgets/artistry-bridge'
import { setupAutoUpdater } from './services/electron/auto-updater'
import { setupGlobalShortcutService } from './services/electron/global-shortcut'
import { setupMediaPermissionHandlers } from './services/electron/media-permissions'
import { setupTray } from './tray'
import { setupAboutWindowReusable } from './windows/about'
import { setupBeatSync } from './windows/beat-sync'
@ -115,8 +114,6 @@ app.whenReady().then(async () => {
return
}
setupMediaPermissionHandlers(session.defaultSession)
// Initialize file logger and register the hook
fileLogger = await setupFileLogger()
@ -214,11 +211,7 @@ app.whenReady().then(async () => {
const settingsWindow = injeca.provide('windows:settings', {
dependsOn: { widgetsManager, beatSync, autoUpdater, devtoolsWindow: devtoolsMarkdownStressWindow, serverChannel, godotStageManager, mcpStdioManager, i18n, windowAuthManager, globalShortcut, spotlightWindow },
build: async ({ dependsOn }) =>
setupSettingsWindowReusableFunc({
...dependsOn,
getMainWindow: () => userFacingMainWindow,
}),
build: async ({ dependsOn }) => setupSettingsWindowReusableFunc(dependsOn),
})
const mainWindow = injeca.provide('windows:main', {

View file

@ -1,35 +0,0 @@
import { env } from 'node:process'
/**
* Checks whether a URL belongs to an AIRI-owned local renderer page.
*
* Use when:
* - Electron main-process policies need to distinguish AIRI pages from remote content
* - Packaged and development renderer URLs must share the same trust decision
*
* Expects:
* - Packaged pages use file URLs
* - Development pages share the exact origin configured by Electron Vite
*
* Returns:
* - Whether the URL uses the packaged file scheme or the configured renderer origin
*/
export function isLocalAppURL(rawURL: string | undefined): boolean {
if (!rawURL)
return false
try {
const url = new URL(rawURL)
if (url.protocol === 'file:')
return true
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !env.ELECTRON_RENDERER_URL)
return false
const rendererURL = new URL(env.ELECTRON_RENDERER_URL)
return url.origin === rendererURL.origin
}
catch {
return false
}
}

View file

@ -105,7 +105,7 @@ export function createAuthService(params: {
const state = generateState()
// Start loopback server to receive the callback
const loopback = await startLoopbackServer(state)
const loopback = await startLoopbackServer()
closeLoopback = loopback.close
// Use the server-side relay as redirect_uri. The relay page serves HTML
@ -134,7 +134,13 @@ export function createAuthService(params: {
// Wait for the callback in the background
loopback.result
.then(async ({ code }) => {
.then(async ({ code, state: returnedState }) => {
if (returnedState !== state) {
log.warn('State mismatch — possible CSRF attack')
params.windowAuthManager.broadcastAuthError('State mismatch')
return
}
const tokens = await exchangeCode(code, codeVerifier, redirectUri)
params.windowAuthManager.broadcastAuthCallback(tokens)
log.log('OIDC token exchange successful')

View file

@ -2,10 +2,6 @@ import { afterEach, describe, expect, it } from 'vitest'
import { startLoopbackServer } from './index'
/**
* @example
* const server = await startLoopbackServer('expected-state')
*/
describe('startLoopbackServer', () => {
const servers: Array<Awaited<ReturnType<typeof startLoopbackServer>>> = []
@ -16,45 +12,13 @@ describe('startLoopbackServer', () => {
}
})
/** @example A callback with the expected state resolves the authorization code. */
it('returns the code from a callback with the expected state', async () => {
const server = await startLoopbackServer('state-1')
it('returns code and state from callback', async () => {
const server = await startLoopbackServer()
servers.push(server)
const response = await fetch(`http://127.0.0.1:${server.port}/callback?code=ok&state=state-1`)
expect(response.status).toBe(200)
await expect(server.result).resolves.toEqual({ code: 'ok' })
})
/** @example A forged callback cannot consume the one-shot server before the valid callback. */
it('rejects a mismatched state without settling the login attempt', async () => {
const server = await startLoopbackServer('expected-state')
servers.push(server)
const forgedResponse = await fetch(`http://127.0.0.1:${server.port}/callback?code=forged&state=wrong-state`)
expect(forgedResponse.status).toBe(400)
const validResponse = await fetch(`http://127.0.0.1:${server.port}/callback?code=valid&state=expected-state`)
expect(validResponse.status).toBe(200)
await expect(server.result).resolves.toEqual({ code: 'valid' })
})
/** @example The web relay receives ordinary CORS without the obsolete PNA response header. */
it('keeps standard CORS for the relay without private-network access headers', async () => {
const server = await startLoopbackServer('state-1')
servers.push(server)
const response = await fetch(`http://127.0.0.1:${server.port}/callback?code=ok&state=state-1`, {
headers: {
Origin: 'https://accounts.airi.build',
},
})
expect(response.status).toBe(200)
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*')
expect(response.headers.get('Access-Control-Allow-Private-Network')).toBeNull()
await expect(server.result).resolves.toEqual({ code: 'ok' })
await expect(server.result).resolves.toEqual({ code: 'ok', state: 'state-1' })
})
})

View file

@ -2,12 +2,9 @@ import { eventHandler, getQuery, H3, handleCors } from 'h3'
import { createH3Server } from '../../server'
/**
* Validated authorization data returned by the temporary loopback server.
*/
export interface LoopbackCallbackResult {
/** Authorization code accepted only after the OIDC state matches. */
code: string
state: string
}
/**
@ -17,14 +14,13 @@ export interface LoopbackCallbackResult {
* - Exchanging authorization code from system browser callback
*
* Expects:
* - `expectedState` is the high-entropy state generated for this login attempt
* - Callback request on `GET /callback?code=...&state=...`
* - One-shot lifecycle; the first callback with matching state closes the server
* - One-shot lifecycle; first successful callback closes the server
*
* Returns:
* - Random bound port, callback result promise, and manual cancellation method
*/
export async function startLoopbackServer(expectedState: string): Promise<{
export async function startLoopbackServer(): Promise<{
port: number
result: Promise<LoopbackCallbackResult>
close: () => void
@ -51,15 +47,6 @@ export async function startLoopbackServer(expectedState: string): Promise<{
},
} as const
// NOTICE:
// Standard CORS lets configured web relay origins read successful handoff responses.
// A simple cross-origin GET is still sent regardless of CORS response headers, so OIDC state validation is the authorization boundary.
// Source/context: `https://developer.chrome.com/blog/local-network-access`.
// Removal condition: the relay moves to same-origin transport or top-level navigation only.
/**
* Settles the one-shot callback result and stops the loopback listener.
*/
const finish = (callback: () => void) => {
if (settled) {
return
@ -90,14 +77,6 @@ export async function startLoopbackServer(expectedState: string): Promise<{
}
const query = getQuery(event)
const state = typeof query.state === 'string' ? query.state : ''
if (!state || state !== expectedState) {
return new Response('<html><body><h2>Invalid state</h2></body></html>', {
status: 400,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
})
}
const error = typeof query.error === 'string' ? query.error : undefined
if (error) {
const description = typeof query.error_description === 'string' && query.error_description.length > 0
@ -113,7 +92,9 @@ export async function startLoopbackServer(expectedState: string): Promise<{
}
const code = typeof query.code === 'string' ? query.code : ''
if (!code) {
const state = typeof query.state === 'string' ? query.state : ''
if (!code || !state) {
return new Response('<html><body><h2>Missing parameters</h2></body></html>', {
status: 400,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
@ -121,7 +102,7 @@ export async function startLoopbackServer(expectedState: string): Promise<{
}
finish(() => {
resolveResult({ code })
resolveResult({ code, state })
})
return new Response('<html><body><h2>Authentication successful!</h2><p>You can close this window and return to the app.</p></body></html>', {

View file

@ -3,5 +3,4 @@ export * from './auto-updater'
export * from './global-shortcut'
export * from './powerMonitor'
export * from './screen'
export * from './system-preferences'
export * from './window'

View file

@ -1,225 +0,0 @@
import type { MediaAccessPermissionRequest, PermissionCheckHandlerHandlerDetails, WebContents } from 'electron'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { shouldGrantAudioCapturePermission, shouldGrantElectronPermission } from './media-permissions'
const localWebContents = {
getURL: () => 'file:///app/index.html',
} satisfies Pick<WebContents, 'getURL'>
/**
* Creates official Electron request details for media permission tests.
*/
function createMediaRequestDetails(overrides: Partial<MediaAccessPermissionRequest> = {}): MediaAccessPermissionRequest {
return {
isMainFrame: true,
requestingUrl: 'file:///app/index.html',
...overrides,
}
}
/**
* Creates official Electron check details for media permission tests.
*/
function createPermissionCheckDetails(overrides: Partial<PermissionCheckHandlerHandlerDetails> = {}): PermissionCheckHandlerHandlerDetails {
return {
isMainFrame: true,
...overrides,
}
}
/**
* @example
* shouldGrantElectronPermission(localWebContents, 'media', origin, details)
*/
describe('media permissions', () => {
beforeEach(() => {
vi.stubEnv('ELECTRON_RENDERER_URL', 'http://localhost:5173')
})
afterEach(() => {
vi.unstubAllEnvs()
})
/** @example Local packaged pages may request audio-only media. */
it('grants local audio media permission requests', () => {
expect(shouldGrantAudioCapturePermission(
localWebContents,
'media',
undefined,
createMediaRequestDetails({ mediaTypes: ['audio'] }),
)).toBe(true)
})
/** @example Camera-only requests remain denied. */
it('rejects video-only media permission requests', () => {
expect(shouldGrantAudioCapturePermission(
localWebContents,
'media',
undefined,
createMediaRequestDetails({ mediaTypes: ['video'] }),
)).toBe(false)
})
/** @example Combined microphone and camera requests remain denied. */
it('rejects media permission requests that include video', () => {
expect(shouldGrantAudioCapturePermission(
localWebContents,
'media',
undefined,
createMediaRequestDetails({ mediaTypes: ['audio', 'video'] }),
)).toBe(false)
})
/** @example A generic media request without a declared audio type is not inferred as safe. */
it('does not treat missing media details as audio', () => {
expect(shouldGrantAudioCapturePermission(
localWebContents,
'media',
undefined,
createMediaRequestDetails(),
)).toBe(false)
})
/** @example Electron permission checks report audio through mediaType. */
it('grants local audio permission checks', () => {
expect(shouldGrantAudioCapturePermission(
null,
'media',
'file:///app/index.html',
createPermissionCheckDetails({ mediaType: 'audio' }),
)).toBe(true)
})
/** @example A remote top-level origin cannot request the microphone. */
it('rejects audio requests from non-local origins', () => {
expect(shouldGrantAudioCapturePermission(
null,
'media',
'https://example.com',
createPermissionCheckDetails({ mediaType: 'audio' }),
)).toBe(false)
})
/** @example A remote requesting frame is rejected even inside a local BrowserWindow. */
it('rejects remote frame requests even when the host window is local', () => {
expect(shouldGrantAudioCapturePermission(
localWebContents,
'media',
undefined,
createMediaRequestDetails({ mediaTypes: ['audio'], requestingUrl: 'https://example.com/frame.html' }),
)).toBe(false)
})
/** @example A local child frame embedded by a remote page is not AIRI-owned. */
it('rejects local frames embedded by a remote origin', () => {
expect(shouldGrantAudioCapturePermission(
null,
'media',
'http://localhost:5173',
createPermissionCheckDetails({
embeddingOrigin: 'https://example.com',
mediaType: 'audio',
securityOrigin: 'http://localhost:5173',
}),
)).toBe(false)
})
/** @example All explicit requester identities are accepted when they remain local. */
it('grants audio requests with explicit local requester URLs', () => {
expect(shouldGrantAudioCapturePermission(
null,
'media',
'http://localhost:5173',
createPermissionCheckDetails({
mediaType: 'audio',
requestingUrl: 'http://localhost:5173',
securityOrigin: 'http://localhost:5173',
}),
)).toBe(true)
})
/** @example Extension assets served from AIRI's loopback server remain untrusted. */
it('rejects plugin asset frames served from a loopback origin', () => {
// ROOT CAUSE:
//
// Treating every loopback HTTP origin as AIRI-owned also trusts extension UI frames.
// Those frames use the same loopback transport but do not share the renderer origin.
// We fixed this by matching HTTP origins against ELECTRON_RENDERER_URL exactly.
expect(shouldGrantAudioCapturePermission(
null,
'media',
'http://127.0.0.1:48123',
createPermissionCheckDetails({
mediaType: 'audio',
requestingUrl: 'http://127.0.0.1:48123/_airi/extensions/example/sessions/session/ui/index.html',
securityOrigin: 'http://127.0.0.1:48123',
}),
)).toBe(false)
})
/** @example A plugin development server cannot inherit AIRI renderer permissions. */
it('rejects plugin frames served from another localhost port', () => {
expect(shouldGrantAudioCapturePermission(
null,
'media',
'http://localhost:4173',
createPermissionCheckDetails({
mediaType: 'audio',
requestingUrl: 'http://localhost:4173/index.html',
securityOrigin: 'http://localhost:4173',
}),
)).toBe(false)
})
/** @example Chromium's opaque origin does not override an explicit packaged file URL. */
it('ignores opaque file origins when packaged local pages request audio', () => {
expect(shouldGrantAudioCapturePermission(
localWebContents,
'media',
'null',
createMediaRequestDetails({ mediaTypes: ['audio'] }),
)).toBe(true)
})
/** @example Local AIRI pages retain screen-capture access. */
it('grants display capture requests from local app pages', () => {
expect(shouldGrantElectronPermission(
localWebContents,
'display-capture',
undefined,
createMediaRequestDetails(),
)).toBe(true)
})
/** @example Remote frames cannot invoke screen capture through the global session handler. */
it('rejects display capture requests from remote pages', () => {
expect(shouldGrantElectronPermission(
localWebContents,
'display-capture',
undefined,
createMediaRequestDetails({ requestingUrl: 'https://example.com/capture.html' }),
)).toBe(false)
})
/** @example Local AIRI pages retain sanitized clipboard writes used by chat copy actions. */
it('grants sanitized clipboard writes from local app pages', () => {
expect(shouldGrantElectronPermission(
localWebContents,
'clipboard-sanitized-write',
'file:///app/index.html',
createPermissionCheckDetails(),
)).toBe(true)
})
/** @example Unreviewed permission categories are denied by default. */
it('rejects unrelated permissions instead of granting all local requests', () => {
expect(shouldGrantElectronPermission(
localWebContents,
'notifications',
'file:///app/index.html',
createPermissionCheckDetails(),
)).toBe(false)
})
})

View file

@ -1,132 +0,0 @@
import type { Session, WebContents } from 'electron'
import { isLocalAppURL } from '../../libs/electron/url'
type PermissionCheckHandler = Exclude<Parameters<Session['setPermissionCheckHandler']>[0], null>
type PermissionRequestHandler = Exclude<Parameters<Session['setPermissionRequestHandler']>[0], null>
type ElectronPermission = Parameters<PermissionCheckHandler>[1] | Parameters<PermissionRequestHandler>[1]
type ElectronPermissionDetails = Parameters<PermissionCheckHandler>[3] | Parameters<PermissionRequestHandler>[3]
type LocalAppWebContents = Pick<WebContents, 'getURL'>
const LOCAL_APP_PERMISSION_NAMES = new Set<ElectronPermission>([
'display-capture',
'clipboard-sanitized-write',
])
/**
* Filters out Chromium's opaque origin marker before evaluating explicit frame URLs.
*/
function isUsableRequesterURL(rawURL: string | undefined): rawURL is string {
return !!rawURL && rawURL !== 'null'
}
/**
* Checks whether Electron described an audio-only media permission operation.
*/
function isAudioMediaPermission(permission: ElectronPermission, details?: ElectronPermissionDetails): boolean {
if (permission !== 'media' || !details)
return false
if ('mediaTypes' in details && details.mediaTypes?.length) {
return details.mediaTypes.includes('audio') && !details.mediaTypes.includes('video')
}
return 'mediaType' in details && details.mediaType === 'audio'
}
/**
* Checks whether every requester identity supplied by Electron is local to AIRI.
*/
function shouldGrantLocalAppPermission(
webContents: LocalAppWebContents | null,
requestingOrigin?: string,
details?: ElectronPermissionDetails,
): boolean {
const requesterURLs = [
requestingOrigin,
details?.requestingUrl,
details && 'securityOrigin' in details ? details.securityOrigin : undefined,
details && 'embeddingOrigin' in details ? details.embeddingOrigin : undefined,
].filter(isUsableRequesterURL)
if (requesterURLs.length)
return requesterURLs.every(isLocalAppURL)
return isLocalAppURL(webContents?.getURL())
}
/**
* Decides whether an Electron media operation is an AIRI-owned audio-only request.
*
* Use when:
* - Chromium asks the default session to check or request microphone access
* - A caller needs the same local-frame policy outside the session callbacks
*
* Expects:
* - Permission details come from Electron's official request or check handler contracts
* - Packaged pages use file URLs and development pages use loopback HTTP URLs
*
* Returns:
* - Whether the operation is audio-only and every supplied requester identity is local
*/
export function shouldGrantAudioCapturePermission(
webContents: LocalAppWebContents | null,
permission: ElectronPermission,
requestingOrigin?: string,
details?: ElectronPermissionDetails,
): boolean {
return isAudioMediaPermission(permission, details)
&& shouldGrantLocalAppPermission(webContents, requestingOrigin, details)
}
/**
* Applies AIRI's allowlist to an Electron session permission operation.
*
* Use when:
* - Wiring both Electron permission check and request handlers
* - Preserving reviewed local display-capture and clipboard behavior
*
* Expects:
* - Unknown or unreviewed permission categories must remain denied
* - All explicit frame, security, and embedding origins must identify local AIRI pages
*
* Returns:
* - Whether the requested permission is both allowlisted and locally owned
*/
export function shouldGrantElectronPermission(
webContents: LocalAppWebContents | null,
permission: ElectronPermission,
requestingOrigin?: string,
details?: ElectronPermissionDetails,
): boolean {
if (permission === 'media')
return shouldGrantAudioCapturePermission(webContents, permission, requestingOrigin, details)
return LOCAL_APP_PERMISSION_NAMES.has(permission)
&& shouldGrantLocalAppPermission(webContents, requestingOrigin, details)
}
/**
* Registers the paired Electron session handlers required for complete permission policy.
*
* Use when:
* - Initializing Electron's default session after app readiness
*
* Expects:
* - The session is the one used by AIRI renderer windows
* - macOS systemPreferences remains responsible for OS-level consent prompts and status
*
* Returns:
* - Nothing; both handlers are installed on the supplied session
*/
export function setupMediaPermissionHandlers(
targetSession: Pick<Session, 'setPermissionCheckHandler' | 'setPermissionRequestHandler'>,
): void {
targetSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
callback(shouldGrantElectronPermission(webContents, permission, undefined, details))
})
targetSession.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => {
return shouldGrantElectronPermission(webContents, permission, requestingOrigin, details)
})
}

View file

@ -15,7 +15,7 @@ import { defineInvokeHandler } from '@moeru/eventa'
import { createContext } from '@moeru/eventa/adapters/electron/main'
import { ipcMain } from 'electron'
import { electronCenterMainWindow, electronOpenChat, electronOpenMainDevtools, electronOpenSettings, noticeWindowEventa } from '../../../../shared/eventa'
import { electronOpenChat, electronOpenMainDevtools, electronOpenSettings, noticeWindowEventa } from '../../../../shared/eventa'
import { createAuthService } from '../../../services/airi/auth'
import { createGodotStageService } from '../../../services/airi/godot-stage'
import { createMcpServersService } from '../../../services/airi/mcp-servers'
@ -23,7 +23,6 @@ import { createOnboardingService } from '../../../services/airi/onboarding'
import { createWidgetsService } from '../../../services/airi/widgets'
import { createAutoUpdaterService } from '../../../services/electron'
import { toggleWindowShow } from '../../shared'
import { centerWindowOnDisplay } from '../../shared/display'
import { setupBaseWindowElectronInvokes } from '../../shared/window'
export async function setupMainWindowElectronInvokes(params: {
@ -55,7 +54,6 @@ export async function setupMainWindowElectronInvokes(params: {
createOnboardingService({ context, onboardingWindowManager: params.onboardingWindowManager, mainWindow: params.window })
createAuthService({ context, window: params.window, windowAuthManager: params.windowAuthManager })
defineInvokeHandler(context, electronCenterMainWindow, () => centerWindowOnDisplay(params.window))
defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' }))
defineInvokeHandler(context, electronOpenSettings, payload => params.settingsWindow.openWindow(payload?.route))
defineInvokeHandler(context, electronOpenChat, async () => toggleWindowShow(await params.chatWindow()))

View file

@ -31,7 +31,6 @@ export function setupSettingsWindowReusableFunc(params: {
widgetsManager: WidgetsWindowManager
autoUpdater: AutoUpdater
devtoolsWindow: DevtoolsWindowManager
getMainWindow?: () => BrowserWindow | undefined
onWindowCreated?: (window: BrowserWindow) => void
serverChannel: ServerChannel
godotStageManager: GodotStageManager
@ -74,7 +73,6 @@ export function setupSettingsWindowReusableFunc(params: {
widgetsManager: params.widgetsManager,
autoUpdater: params.autoUpdater,
devtoolsWindow: params.devtoolsWindow,
getMainWindow: params.getMainWindow,
serverChannel: params.serverChannel,
godotStageManager: params.godotStageManager,
mcpStdioManager: params.mcpStdioManager,

View file

@ -16,7 +16,6 @@ import { createContext } from '@moeru/eventa/adapters/electron/main'
import { ipcMain } from 'electron'
import {
electronCenterMainWindow,
electronOpenDevtoolsWindow,
electronOpenSettingsDevtools,
electronSpotlightShortcutGet,
@ -27,7 +26,6 @@ import { createGodotStageService } from '../../../services/airi/godot-stage'
import { createMcpServersService } from '../../../services/airi/mcp-servers'
import { createWidgetsService } from '../../../services/airi/widgets'
import { createAutoUpdaterService } from '../../../services/electron'
import { centerWindowOnDisplay } from '../../shared/display'
import { setupBaseWindowElectronInvokes } from '../../shared/window'
export async function setupSettingsWindowInvokes(params: {
@ -35,7 +33,6 @@ export async function setupSettingsWindowInvokes(params: {
widgetsManager: WidgetsWindowManager
autoUpdater: AutoUpdater
devtoolsWindow: DevtoolsWindowManager
getMainWindow?: () => BrowserWindow | undefined
serverChannel: ServerChannel
godotStageManager: GodotStageManager
mcpStdioManager: McpStdioManager
@ -62,7 +59,6 @@ export async function setupSettingsWindowInvokes(params: {
// Register the global shortcut service for the settings window.
params.globalShortcut.registerWindow({ context, window: params.settingsWindow })
defineInvokeHandler(context, electronCenterMainWindow, () => centerWindowOnDisplay(params.getMainWindow?.()))
defineInvokeHandler(context, electronSpotlightShortcutGet, () => params.spotlightWindow.getShortcutAccelerator())
defineInvokeHandler(context, electronSpotlightShortcutSet, (payload) => {
if (payload?.accelerator === undefined)

View file

@ -1,16 +1,8 @@
import type { Rectangle } from 'electron'
import { screen } from 'electron'
import { describe, expect, it, vi } from 'vitest'
import {
centerWindowOnDisplay,
computeCenteredWindowBounds,
computeResizedBoundsAnchoredToDominantDisplay,
heightFrom,
mapForBreakpoints,
widthFrom,
} from './display'
import { computeResizedBoundsAnchoredToDominantDisplay, heightFrom, mapForBreakpoints, widthFrom } from './display'
// NOTICE:
// Mocking 'electron' is needed to prevent Vitest from attempting to resolve/load the real Electron binary during tests.
@ -140,102 +132,3 @@ describe('computeResizedBoundsAnchoredToDominantDisplay', () => {
expect(bounds.height).toBe(600)
})
})
/**
* @example
* computeCenteredWindowBounds({ displayWorkArea, windowBounds })
*/
describe('computeCenteredWindowBounds', () => {
/**
* @example
* A 450x600 window is centered without changing its size.
*/
it('preserves the window size and centers it inside the display work area', () => {
const result = computeCenteredWindowBounds({
displayWorkArea: { x: 0, y: 25, width: 1440, height: 875 },
windowBounds: { x: 1200, y: 700, width: 450, height: 600 },
})
expect(result).toEqual({ x: 495, y: 162, width: 450, height: 600 })
})
/**
* @example
* A display above and left of the primary screen keeps negative coordinates.
*/
it('supports display work areas with negative origins', () => {
const result = computeCenteredWindowBounds({
displayWorkArea: { x: -1920, y: -1080, width: 1920, height: 1055 },
windowBounds: { x: -2300, y: -1300, width: 500, height: 620 },
})
expect(result).toEqual({ x: -1210, y: -863, width: 500, height: 620 })
})
/**
* @example
* An oversized window starts at the work-area origin instead of moving farther off-screen.
*/
it('keeps oversized windows anchored inside the display work area origin', () => {
const result = computeCenteredWindowBounds({
displayWorkArea: { x: 120, y: 45, width: 800, height: 500 },
windowBounds: { x: -2000, y: -900, width: 1000, height: 640 },
})
expect(result).toEqual({ x: 120, y: 45, width: 1000, height: 640 })
})
})
/**
* @example
* centerWindowOnDisplay(window)
*/
describe('centerWindowOnDisplay', () => {
/**
* @example
* The recovered window receives centered bounds and becomes visible.
*/
it('sets centered bounds and shows the window', () => {
const windowBounds = { x: 1200, y: 700, width: 450, height: 600 }
const displayWorkArea = { x: 0, y: 25, width: 1440, height: 875 }
const setBounds = vi.fn()
const show = vi.fn()
vi.mocked(screen.getDisplayMatching).mockReturnValue({ workArea: displayWorkArea } as Electron.Display)
const result = centerWindowOnDisplay({
getBounds: () => windowBounds,
isDestroyed: () => false,
setBounds,
show,
})
expect(result).toEqual({ x: 495, y: 162, width: 450, height: 600 })
expect(screen.getDisplayMatching).toHaveBeenCalledWith(windowBounds)
expect(setBounds).toHaveBeenCalledWith({ x: 495, y: 162, width: 450, height: 600 })
expect(show).toHaveBeenCalledTimes(1)
})
/**
* @example
* A missing main window reports a stable domain error to the renderer.
*/
it('rejects recovery when the target window is unavailable', () => {
expect(() => centerWindowOnDisplay(undefined)).toThrowError('Main AIRI window is not available.')
})
/**
* @example
* A destroyed window is rejected before Electron bounds methods are called.
*/
it('rejects recovery when the target window was destroyed', () => {
const getBounds = vi.fn()
expect(() => centerWindowOnDisplay({
getBounds,
isDestroyed: () => true,
setBounds: vi.fn(),
show: vi.fn(),
})).toThrowError('Main AIRI window is not available.')
expect(getBounds).not.toHaveBeenCalled()
})
})

View file

@ -9,62 +9,6 @@ export function currentDisplayBounds(window: BrowserWindow) {
return nearbyDisplay.bounds
}
/**
* Computes bounds that center a window inside an Electron display work area.
*
* Use when:
* - Recovering a desktop window that was moved outside the visible work area
* - Preserving the current window size while changing only its position
*
* Expects:
* - Both rectangles use Electron logical display coordinates
* - The display work area excludes menu bars, docks, and taskbars
*
* Returns:
* - Centered bounds that preserve the window width and height
*/
export function computeCenteredWindowBounds(options: {
displayWorkArea: Rectangle
windowBounds: Rectangle
}): Rectangle {
const centeredOffsetX = Math.floor((options.displayWorkArea.width - options.windowBounds.width) / 2)
const centeredOffsetY = Math.floor((options.displayWorkArea.height - options.windowBounds.height) / 2)
return {
x: options.displayWorkArea.x + Math.max(0, centeredOffsetX),
y: options.displayWorkArea.y + Math.max(0, centeredOffsetY),
width: options.windowBounds.width,
height: options.windowBounds.height,
}
}
/**
* Centers and reveals an Electron window on the display matching its current bounds.
*
* Use when:
* - A renderer requests recovery of an off-screen AIRI window
* - A hidden window must become visible after its position is restored
*
* Expects:
* - The window is alive and supports Electron's bounds APIs
*
* Returns:
* - The centered bounds applied to the window
*/
export function centerWindowOnDisplay(window: Pick<BrowserWindow, 'getBounds' | 'isDestroyed' | 'setBounds' | 'show'> | undefined): Rectangle {
if (!window || window.isDestroyed())
throw new Error('Main AIRI window is not available.')
const windowBounds = window.getBounds()
const displayWorkArea = screen.getDisplayMatching(windowBounds).workArea
const centeredBounds = computeCenteredWindowBounds({ displayWorkArea, windowBounds })
window.setBounds(centeredBounds)
window.show()
return centeredBounds
}
export interface ResizableDisplayArea {
/** Full display bounds used to decide which physical display owns most of a window. */
bounds: Rectangle

View file

@ -10,7 +10,7 @@ import { isMacOS } from 'std-env'
import { createServerChannelService } from '../../services/airi/channel-server'
import { createI18nService } from '../../services/airi/i18n'
import { createAppService, createPowerMonitorService, createScreenService, createSystemPreferencesService, createWindowService } from '../../services/electron'
import { createAppService, createPowerMonitorService, createScreenService, createWindowService } from '../../services/electron'
export function toggleWindowShow(window?: BrowserWindow | null): void {
if (!window) {
@ -100,7 +100,6 @@ export async function setupBaseWindowElectronInvokes(params: {
createWindowService({ context: params.context, window: params.window })
createAppService({ context: params.context, window: params.window })
createPowerMonitorService({ context: params.context, window: params.window })
createSystemPreferencesService({ context: params.context, window: params.window })
await createI18nService({ context: params.context, window: params.window, i18n: params.i18n })

View file

@ -5,7 +5,6 @@ import { themeColorFromValue, useThemeColor } from '@proj-airi/stage-layouts/com
import { artistrySyncConfig } from '@proj-airi/stage-shared'
import { ToasterRoot } from '@proj-airi/stage-ui/components'
import { useInferencePreload } from '@proj-airi/stage-ui/composables'
import { useAuthProviderSync } from '@proj-airi/stage-ui/composables/use-auth-provider-sync'
import { useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
@ -73,9 +72,6 @@ const chatSyncLifecycle = createChatSyncWindowLifecycle(route.path)
const isSpotlightWindowRoute = initialWindowRoutePath === '/spotlight'
const isSettingsWindowRoute = initialWindowRoutePath.startsWith('/settings')
if (!isSpotlightWindowRoute)
useAuthProviderSync()
function createFullStageRuntime() {
const contextBridgeStore = useContextBridgeStore()
const displayModelsStore = useDisplayModelsStore()

View file

@ -19,7 +19,6 @@ import IndicatorMicVolume from './indicator-mic-volume.vue'
import {
electron,
electronAppQuit,
electronCenterMainWindow,
electronOpenChat,
electronOpenSettings,
electronStartDraggingWindow,
@ -39,7 +38,6 @@ const openChat = useElectronEventaInvoke(electronOpenChat)
const isLinux = useElectronEventaInvoke(electron.app.isLinux)
const closeWindow = useElectronEventaInvoke(electronAppQuit)
const setAlwaysOnTop = useElectronEventaInvoke(electronWindowSetAlwaysOnTop)
const centerMainWindow = useElectronEventaInvoke(electronCenterMainWindow)
const expanded = ref(false)
const islandRef = ref<HTMLElement>()
@ -130,13 +128,6 @@ const startDraggingWindow = !isLinux() ? defineInvoke(context.value, electronSta
function refreshWindow() {
window.location.reload()
}
/**
* Requests the main process to move the AIRI desktop window back to screen center.
*/
function resetMainWindowPosition() {
centerMainWindow().catch(console.error)
}
</script>
<template>
@ -196,15 +187,6 @@ function resetMainWindowPosition() {
</template>
</ControlButtonTooltip>
<ControlButtonTooltip disable-hoverable-content>
<ControlButton :button-style="adjustStyleClasses.button" @click="resetMainWindowPosition()">
<div i-solar:target-linear :class="adjustStyleClasses.icon" text="neutral-800 dark:neutral-300" />
</ControlButton>
<template #tooltip>
{{ t('tamagotchi.stage.controls-island.center-main-window') }}
</template>
</ControlButtonTooltip>
<ControlButtonTooltip disable-hoverable-content>
<ControlButton :button-style="adjustStyleClasses.button" @click="toggleDark()">
<Transition name="fade" mode="out-in">

View file

@ -7,14 +7,14 @@ import semver from 'semver'
import { useElectronAutoUpdater, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { AboutContent, BugReportDialog, createBugReportPageContext, MarkdownRenderer } from '@proj-airi/stage-ui/components'
import { useAnalytics, useBreakpoints } from '@proj-airi/stage-ui/composables'
import { useBreakpoints } from '@proj-airi/stage-ui/composables'
import { useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { Button, ContainerError, DoubleCheckButton, FieldSelect, Progress } from '@proj-airi/ui'
import { useClipboard } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { DrawerContent, DrawerDescription, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTitle } from 'vaul-vue'
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { electronGetUpdaterPreferences, electronSetUpdaterPreferences } from '../../shared/eventa'
@ -32,13 +32,6 @@ const {
quitAndInstall,
} = useElectronAutoUpdater()
const {
trackUpdateCheckClicked,
trackUpdateDownloaded,
trackUpdateInstallClicked,
trackSettingsChanged,
} = useAnalytics()
const isDisabled = computed(() => updateState.value.status === 'disabled')
const isLatestVersion = computed(() => {
return updateState.value.status === 'not-available' && !isDisabled.value
@ -126,23 +119,6 @@ const isDowngradeUpdate = computed(() => {
const getUpdaterPreferences = useElectronEventaInvoke(electronGetUpdaterPreferences)
const setUpdaterPreferences = useElectronEventaInvoke(electronSetUpdaterPreferences)
function handleCheckForUpdates() {
trackUpdateCheckClicked({ channel: selectedUpdateChannel.value })
return checkForUpdates()
}
function handleQuitAndInstall() {
trackUpdateInstallClicked({ channel: selectedUpdateChannel.value, version: updateState.value.info?.version })
quitAndInstall()
}
// Fires on the state transition (not the download click) so the event means
// "an update binary actually landed on this machine".
watch(() => updateState.value.status, (status) => {
if (status === 'downloaded')
trackUpdateDownloaded({ channel: selectedUpdateChannel.value, version: updateState.value.info?.version })
})
function handleDownloadClick() {
if (updateState.value.info?.releaseNotes)
showChangelog.value = true
@ -205,16 +181,9 @@ async function setUpdateChannelPreference(channel: UpdateChannelOption) {
isUpdateChannelUpdating.value = true
try {
const previousChannel = selectedUpdateChannel.value
const nextChannel = channel === 'auto' ? undefined : channel as ElectronUpdaterChannel
const preferences = await setUpdaterPreferences({ channel: nextChannel })
selectedUpdateChannel.value = preferences?.channel ?? 'auto'
trackSettingsChanged({
setting_name: 'update_channel',
previous_value: previousChannel,
new_value: selectedUpdateChannel.value,
source: 'settings',
})
await checkForUpdates()
}
finally {
@ -356,7 +325,7 @@ onMounted(() => {
<div>
<DoubleCheckButton
variant="primary"
@confirm="handleQuitAndInstall()"
@confirm="quitAndInstall()"
>
{{ restartButtonLabel }}
<template #confirm>
@ -397,7 +366,7 @@ onMounted(() => {
: isError
? t('tamagotchi.stage.about.update.actions.retry-check')
: t('tamagotchi.stage.about.update.actions.check-for-updates')"
@click="handleCheckForUpdates()"
@click="checkForUpdates()"
/>
</div>
</div>

View file

@ -3,7 +3,9 @@ import type { ModelSettingsRuntimeSnapshot } from '@proj-airi/stage-ui/component
import type { ModelSettingsRuntimeChannelEvent } from '../../shared/model-settings-runtime'
import { errorMessageFrom, tryCatch } from '@moeru/std'
import workletUrl from '@proj-airi/stage-ui/workers/vad/process.worklet?worker&url'
import { tryCatch } from '@moeru/std'
import { electron } from '@proj-airi/electron-eventa'
import {
useElectronEventaInvoke,
@ -12,7 +14,6 @@ import {
useElectronMouseInWindow,
useElectronRelativeMouse,
} from '@proj-airi/electron-vueuse'
import { createTranscriptBuffer } from '@proj-airi/pipelines-audio'
import { IS_DEV } from '@proj-airi/stage-shared'
import { useModelStore, useThreeSceneIsTransparentAtPoint } from '@proj-airi/stage-ui-three'
import { HoloCoupon } from '@proj-airi/stage-ui/components'
@ -21,16 +22,15 @@ import {
resolveComponentStateToRuntimePhase,
} from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime'
import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
import { useVoiceInputSession } from '@proj-airi/stage-ui/composables'
import { useAudioRecorder } from '@proj-airi/stage-ui/composables/audio/audio-recorder'
import { useCanvasPixelIsTransparentAtPoint } from '@proj-airi/stage-ui/composables/canvas-alpha'
import { useSpeakingStore } from '@proj-airi/stage-ui/stores/audio'
import { useHearingSpeechInputPipeline, useHearingStore } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useVAD } from '@proj-airi/stage-ui/stores/ai/models/vad'
import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { refDebounced, useBroadcastChannel } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, ref, shallowRef, toRef, watch } from 'vue'
import { toast } from 'vue-sonner'
import { computed, onMounted, onUnmounted, ref, toRef, watch } from 'vue'
import ControlsIsland from '../components/stage-islands/controls-island/index.vue'
import ResourceStatusIsland from '../components/stage-islands/resource-status-island/index.vue'
@ -42,12 +42,6 @@ import { useChatSyncStore } from '../stores/chat-sync'
import { useControlsIslandStore } from '../stores/controls-island'
import { useStageWindowLifecycleStore } from '../stores/stage-window-lifecycle'
import { shouldSampleStageTransparency } from '../utils/stage-three-transparency'
import { createVoiceInputInteractionLifecycle } from '../utils/voice-input-lifecycle'
import {
assistantSpeechCooldownDeadline,
DEFAULT_ASSISTANT_SPEECH_INPUT_COOLDOWN_MS,
shouldSuppressVoiceInput,
} from '../utils/voice-input-suppression'
const controlsIslandRef = ref<InstanceType<typeof ControlsIsland>>()
const statusIslandRef = ref<InstanceType<typeof StatusIsland>>()
@ -228,60 +222,39 @@ watch([isOutsideFor250Ms, isOutsideStatusIslandFor250Ms, isAroundWindowBorderFor
})
// Emit runtime snapshot on change and on request from settings panel
/**
* Sends model-settings runtime events without letting closed HMR channels break the stage.
*/
function postModelSettingsRuntimeEvent(event: ModelSettingsRuntimeChannelEvent) {
const { error } = tryCatch(() => postModelSettingsRuntimeChannelEvent(event))
if (error)
console.warn('[Main Page] Failed to post model settings runtime event:', error)
}
watch(modelSettingsRuntimeSnapshot, (snapshot) => {
postModelSettingsRuntimeEvent({ type: 'snapshot', snapshot })
postModelSettingsRuntimeChannelEvent({ type: 'snapshot', snapshot })
}, { immediate: true })
watch(modelSettingsRuntimeChannelEvent, (event) => {
if (event?.type !== 'request-current')
return
postModelSettingsRuntimeEvent({ type: 'snapshot', snapshot: modelSettingsRuntimeSnapshot.value })
postModelSettingsRuntimeChannelEvent({ type: 'snapshot', snapshot: modelSettingsRuntimeSnapshot.value })
})
const settingsAudioDeviceStore = useSettingsAudioDevice()
const { stream, enabled } = storeToRefs(settingsAudioDeviceStore)
const { askPermission, startStream, stopStream } = settingsAudioDeviceStore
const { nowSpeaking } = storeToRefs(useSpeakingStore())
const hearingStore = useHearingStore()
const { activeTranscriptionModel, activeTranscriptionProvider } = storeToRefs(hearingStore)
const { askPermission } = settingsAudioDeviceStore
const { startRecord, stopRecord, onStopRecord } = useAudioRecorder(stream)
const hearingPipeline = useHearingSpeechInputPipeline()
const { transcribeForMediaStream, stopStreamingTranscription } = hearingPipeline
const { error: transcriptionError, supportsStreamInput } = storeToRefs(hearingPipeline)
const { transcribeForRecording, transcribeForMediaStream, stopStreamingTranscription } = hearingPipeline
const { supportsStreamInput } = storeToRefs(hearingPipeline)
const chatSyncStore = useChatSyncStore()
const streamingTranscriptionUnavailable = ref(false)
const shouldUseStreamInput = computed(() => supportsStreamInput.value && !!stream.value && !streamingTranscriptionUnavailable.value)
const voiceTranscriptBuffer = createTranscriptBuffer({
flushDelayMs: 1200,
maxBufferedTextLength: 90,
async flush(text) {
await sendVoiceInputTextToChat(text)
const shouldUseStreamInput = computed(() => supportsStreamInput.value && !!stream.value)
const { init: initVAD, dispose: disposeVAD, start: startVAD, loaded: vadLoaded } = useVAD(workletUrl, {
threshold: ref(0.6),
onSpeechStart: () => {
void handleSpeechStart()
},
onSpeechEnd: () => {
void handleSpeechEnd()
},
})
const assistantSpeechSuppressedUntil = shallowRef(0)
const assistantSpeechResumeTimer = shallowRef<ReturnType<typeof setTimeout>>()
let voiceInputGeneration = 0
/** Controls transcript cleanup while voice input stops. */
interface StopAudioInteractionOptions {
/** Flushes pending transcript text to chat before stop completes. */
flushTranscript?: boolean
}
const voiceInputInteractionLifecycle = createVoiceInputInteractionLifecycle<StopAudioInteractionOptions>({
start: startAudioInteractionConsumers,
stop: stopAudioInteractionConsumers,
})
let stopOnStopRecord: (() => void) | undefined
const audioInteractionStarting = ref(false)
// Caption overlay broadcast channel
type CaptionChannelEvent
@ -289,319 +262,160 @@ type CaptionChannelEvent
| { type: 'caption-assistant', text: string }
const { post: postCaption } = useBroadcastChannel<CaptionChannelEvent, CaptionChannelEvent>({ name: 'airi-caption-overlay' })
/**
* Reports a voice input pipeline failure to both the console and visible app UI.
*/
function reportVoiceInputFailure(action: string, error: unknown) {
const reason = errorMessageFrom(error)
const message = reason
? `Voice input failed to ${action}: ${reason}`
: `Voice input failed to ${action}.`
console.error(`[Main Page] ${message}`, error)
toast.error(message)
}
/**
* Checks whether current voice input should be ignored to avoid assistant self-transcription.
*/
function isVoiceInputSuppressed(now = Date.now()) {
return shouldSuppressVoiceInput({
assistantSpeaking: nowSpeaking.value,
suppressedUntil: assistantSpeechSuppressedUntil.value,
}, now)
}
/**
* Captures whether a queued VAD segment can still leave the app for ASR.
*/
function inspectVoiceInputProviderRequestGate(generation: unknown) {
const current = generation === voiceInputGeneration
const audioEnabled = enabled.value
const suppressed = isVoiceInputSuppressed()
let reason: string | undefined
if (!current)
reason = 'Skipped stale voice input segment'
else if (!audioEnabled)
reason = 'Skipped voice input segment because audio input is disabled'
else if (suppressed)
reason = 'Skipped voice input segment while assistant speech is active or cooling down'
return {
generation,
activeGeneration: voiceInputGeneration,
current,
enabled: audioEnabled,
suppressed,
reason,
skip: !current || !audioEnabled || suppressed,
}
}
/**
* Captures whether live microphone audio can still leave the app for streaming ASR.
*/
function inspectVoiceInputStreamingRequestGate() {
const audioEnabled = enabled.value
const suppressed = isVoiceInputSuppressed()
return {
enabled: audioEnabled,
suppressed,
skip: !audioEnabled || suppressed,
}
}
/**
* Clears the pending assistant-speech resume timer.
*/
function clearAssistantSpeechResumeTimer() {
if (!assistantSpeechResumeTimer.value)
return
clearTimeout(assistantSpeechResumeTimer.value)
assistantSpeechResumeTimer.value = undefined
}
/**
* Restarts voice input after assistant playback tail audio should be gone.
*/
function scheduleAssistantSpeechResume() {
clearAssistantSpeechResumeTimer()
if (!enabled.value)
return
const remainingCooldownMs = Math.max(
0,
assistantSpeechSuppressedUntil.value
? assistantSpeechSuppressedUntil.value - Date.now()
: DEFAULT_ASSISTANT_SPEECH_INPUT_COOLDOWN_MS,
)
const cooldownMs = nowSpeaking.value
? DEFAULT_ASSISTANT_SPEECH_INPUT_COOLDOWN_MS
: remainingCooldownMs
assistantSpeechResumeTimer.value = setTimeout(() => {
assistantSpeechResumeTimer.value = undefined
if (!enabled.value || isVoiceInputSuppressed())
return
void voiceInputInteractionLifecycle.start().catch(error => reportVoiceInputFailure('resume listening', error))
}, cooldownMs)
}
/**
* Ensures the microphone stream has a live audio track before binding recorder or VAD.
*/
async function ensureLiveAudioInputStream() {
if (!enabled.value)
return false
if (stream.value?.getAudioTracks().some(track => track.readyState === 'live'))
return true
stopStream()
if (!enabled.value)
return false
await askPermission()
if (!enabled.value)
return false
await startStream()
if (!enabled.value) {
stopStream()
return false
}
if (stream.value?.getAudioTracks().some(track => track.readyState === 'live'))
return true
throw new Error('Microphone stream did not provide a live audio track')
}
/**
* Sends voice captions as best-effort overlay updates without interrupting chat ingestion.
*/
function postSpeakerCaption(text: string) {
const { error } = tryCatch(() => postCaption({ type: 'caption-speaker', text }))
if (error)
console.warn('[Main Page] Failed to post voice input caption:', error)
}
/**
* Sends buffered voice input text to the active chat session.
*/
async function sendVoiceInputTextToChat(text: string) {
try {
await chatSyncStore.requestIngest({ text })
}
catch (err) {
reportVoiceInputFailure('send to chat', err)
}
}
/** Sends completed streaming-ASR sentences to captions and chat. */
function handleStreamingSentenceEnd(delta: string) {
if (isVoiceInputSuppressed())
return
console.info('[Main Page] Received transcription delta:', delta)
const finalText = delta
if (!finalText || !finalText.trim())
return
postSpeakerCaption(finalText)
void sendVoiceInputTextToChat(finalText)
}
/** Publishes the provider's final streaming-ASR text to the caption overlay. */
function handleStreamingSpeechEnd(text: string) {
if (isVoiceInputSuppressed())
return
postSpeakerCaption(text)
}
/** Reads the listening generation attached to recorder-backed transcription metadata. */
function getVoiceInputGeneration(metadata?: Record<string, unknown>) {
return typeof metadata?.generation === 'number' ? metadata.generation : undefined
}
const voiceInputSession = useVoiceInputSession(stream, {
shouldUseStreamInput,
canStartSegment: () => enabled.value && !isVoiceInputSuppressed(),
inspectBeforeTranscription: ({ metadata }) => inspectVoiceInputProviderRequestGate(getVoiceInputGeneration(metadata)),
inspectAfterTranscription: ({ metadata }) => inspectVoiceInputProviderRequestGate(getVoiceInputGeneration(metadata)),
onRecordingReady: () => ({ generation: voiceInputGeneration }),
onTranscriptionResult: ({ text }) => {
postSpeakerCaption(text)
toast(`Voice input transcribed: ${text}`)
voiceTranscriptBuffer.push(text)
},
onTranscriptionEmpty: () => {
if (transcriptionError.value) {
reportVoiceInputFailure('transcribe speech', transcriptionError.value)
return
}
toast('Voice input transcribed no text.')
},
onTranscriptionError: ({ error }) => {
reportVoiceInputFailure('transcribe speech', error)
},
})
/** Starts the active streaming or recorder-backed voice-input consumers. */
async function startAudioInteractionConsumers() {
if (isVoiceInputSuppressed()) {
scheduleAssistantSpeechResume()
if (!finalText || !finalText.trim()) {
return
}
if (!await ensureLiveAudioInputStream())
postCaption({ type: 'caption-speaker', text: finalText })
void (async () => {
try {
console.info('[Main Page] Sending transcription to chat:', finalText)
await chatSyncStore.requestIngest({ text: finalText })
}
catch (err) {
console.error('[Main Page] Failed to send chat from voice:', err)
}
})()
}
function handleStreamingSpeechEnd(text: string) {
console.info('[Main Page] Speech ended, final text:', text)
postCaption({ type: 'caption-speaker', text })
}
async function handleSpeechStart() {
if (shouldUseStreamInput.value) {
console.info('Speech detected - transcription session should already be active')
return
}
startRecord()
}
async function handleSpeechEnd() {
if (shouldUseStreamInput.value) {
// Keep streaming session alive; idle timer in pipeline will handle teardown.
return
}
stopRecord()
}
async function startAudioInteraction() {
if (audioInteractionStarting.value)
return
if (shouldUseStreamInput.value) {
const currentStream = stream.value
if (!currentStream)
throw new Error('Microphone stream is unavailable for streaming transcription')
// NOTICE: `stopOnStopRecord` only tracks whether the non-stream recording hook was registered.
//
// It does NOT guarantee that the current realtime transcription session is still attached to the
// latest `MediaStream`. We previously used it as a generic "already started" guard, which broke
// the hearing-config retoggle path: the mic stream was recreated, VAD restarted on the new stream,
// but `transcribeForMediaStream()` never reattached so speech was detected without any transcript.
//
// Keep the startup guard scoped to "startup in progress" only, and let stream changes restart the
// transcription binding when a new stream arrives.
audioInteractionStarting.value = true
try {
console.info('[Main Page] Starting audio interaction...')
const requestGate = inspectVoiceInputStreamingRequestGate()
if (requestGate.skip)
return
await transcribeForMediaStream(currentStream, {
onSentenceEnd: handleStreamingSentenceEnd,
onSpeechEnd: handleStreamingSpeechEnd,
initVAD().then(() => {
if (stream.value) {
console.info('[Main Page] VAD initialized successfully, starting with stream input')
return startVAD(stream.value)
}
}).catch((err) => {
console.warn('[Main Page] VAD initialization failed (non-critical for Web Speech API):', err)
})
if (inspectVoiceInputStreamingRequestGate().skip) {
await stopStreamingTranscription(true)
return
if (shouldUseStreamInput.value) {
console.info('[Main Page] Starting streaming transcription...', {
supportsStreamInput: supportsStreamInput.value,
hasStream: !!stream.value,
})
if (!stream.value) {
console.warn('[Main Page] Stream not available despite shouldUseStreamInput being true')
return
}
// Use sentence deltas for live captions and speech end for final text.
await transcribeForMediaStream(stream.value, {
onSentenceEnd: handleStreamingSentenceEnd,
onSpeechEnd: handleStreamingSpeechEnd,
})
console.info('[Main Page] Streaming transcription started successfully')
}
else {
console.warn('[Main Page] Not starting streaming transcription:', {
shouldUseStreamInput: shouldUseStreamInput.value,
hasStream: !!stream.value,
supportsStreamInput: supportsStreamInput.value,
})
}
if (transcriptionError.value) {
streamingTranscriptionUnavailable.value = true
await stopStreamingTranscription(true)
console.warn('[Main Page] Streaming transcription unavailable; using recorder-backed fallback:', transcriptionError.value)
// NOTICE: This hook is only for record-then-transcribe providers.
//
// Streaming providers use the active `MediaStream` directly, so this callback must not be treated
// as proof that a realtime session is alive. Future refactors should keep recorder-hook bookkeeping
// separate from stream transcription state, otherwise mic/device re-toggles can leave VAD active
// but transcription detached.
//
// Hook once for non-streaming providers.
if (!stopOnStopRecord) {
stopOnStopRecord = onStopRecord(async (recording) => {
if (shouldUseStreamInput.value)
return
const text = await transcribeForRecording(recording)
if (!text || !text.trim())
return
// Update caption overlay speaker text via BroadcastChannel
postCaption({ type: 'caption-speaker', text })
try {
await chatSyncStore.requestIngest({ text })
}
catch (err) {
console.error('Failed to send chat from voice:', err)
}
})
}
}
if (!shouldUseStreamInput.value)
await voiceInputSession.startAutoSegmentation()
catch (e) {
console.error('Audio interaction init failed:', e)
}
finally {
audioInteractionStarting.value = false
}
}
/**
* Stops active microphone consumers before the stage binds to another audio stream.
*/
async function stopAudioInteractionConsumers(options: StopAudioInteractionOptions = {}) {
const flushTranscript = options.flushTranscript ?? true
clearAssistantSpeechResumeTimer()
voiceInputGeneration += 1
await Promise.all([
stopStreamingTranscription(true),
voiceInputSession.stop({ flushActiveRecording: false }),
])
if (flushTranscript)
await voiceTranscriptBuffer.dispose()
else
voiceTranscriptBuffer.clear()
function stopAudioInteraction() {
tryCatch(() => {
stopOnStopRecord?.()
stopOnStopRecord = undefined
audioInteractionStarting.value = false
void stopStreamingTranscription(true)
disposeVAD()
})
}
watch(enabled, async (val) => {
try {
if (val) {
await askPermission()
await voiceInputInteractionLifecycle.start()
}
else {
await voiceInputInteractionLifecycle.stop()
}
console.info('[Main Page] Audio enabled changed:', val, 'stream available:', !!stream.value)
if (val) {
await askPermission()
await startAudioInteraction()
}
catch (error) {
reportVoiceInputFailure(val ? 'start listening' : 'stop listening', error)
if (val)
enabled.value = false
else {
stopAudioInteraction()
}
}, { immediate: true })
watch([activeTranscriptionProvider, activeTranscriptionModel, supportsStreamInput], async () => {
streamingTranscriptionUnavailable.value = false
if (!enabled.value)
return
try {
await voiceInputInteractionLifecycle.stop({ flushTranscript: false })
await voiceInputInteractionLifecycle.start()
}
catch (error) {
reportVoiceInputFailure('restart after transcription settings changed', error)
enabled.value = false
}
})
watch(nowSpeaking, async (speaking) => {
if (speaking) {
clearAssistantSpeechResumeTimer()
try {
await voiceInputInteractionLifecycle.stop({ flushTranscript: false })
}
catch (error) {
reportVoiceInputFailure('pause while assistant is speaking', error)
}
return
}
assistantSpeechSuppressedUntil.value = assistantSpeechCooldownDeadline()
scheduleAssistantSpeechResume()
})
onMounted(() => {
if (onboardingStore.needsOnboarding) {
openOnboarding()
@ -609,29 +423,33 @@ onMounted(() => {
})
onUnmounted(() => {
postModelSettingsRuntimeEvent({
postModelSettingsRuntimeChannelEvent({
type: 'owner-gone',
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,
})
clearAssistantSpeechResumeTimer()
void voiceInputInteractionLifecycle.stop().catch(error => reportVoiceInputFailure('stop listening', error))
stopAudioInteraction()
})
watch(stream, async (currentStream) => {
if (!enabled.value || !currentStream || voiceInputInteractionLifecycle.isStarting() || voiceInputInteractionLifecycle.isStopping() || isVoiceInputSuppressed())
if (!enabled.value || !currentStream || audioInteractionStarting.value)
return
// NOTICE: The controls-island mic toggle and device changes can replace the underlying MediaStream
// without reloading the page. When that happens, VAD may successfully restart against the new stream,
// but any existing transcription transport is still bound to the old one. Always allow the page to
// restart voice input for a newly available stream unless another lifecycle operation is underway.
try {
await voiceInputInteractionLifecycle.stop()
await voiceInputInteractionLifecycle.start()
}
catch (error) {
reportVoiceInputFailure('restart after microphone changed', error)
enabled.value = false
// re-run `startAudioInteraction()` for a newly available stream unless startup is already underway.
console.info('[Main Page] Stream became available, ensuring audio interaction is started')
await startAudioInteraction()
})
watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
if (enabled.value && loaded && s) {
try {
await startVAD(s)
}
catch (e) {
console.error('Failed to start VAD with stream:', e)
}
}
})
@ -679,7 +497,9 @@ const cursorPosition = computed(() => ({
:paused="stagePaused"
/>
<HoloCoupon />
<ControlsIsland ref="controlsIslandRef" />
<ControlsIsland
ref="controlsIslandRef"
/>
</div>
</div>
<!-- Loading overlay sits on top, does not hide the stage -->
@ -724,14 +544,7 @@ const cursorPosition = computed(() => ({
bg="white/80 dark:neutral-950/80" backdrop-blur="md"
>
<div class="wall absolute top-0 h-8" />
<div
:class="[
'absolute left-0 top-0 h-full w-full',
'flex items-center justify-center',
'animate-flash animate-duration-5s animate-count-infinite',
'select-none text-1.5rem text-primary-400 font-normal drag-region',
]"
>
<div class="absolute left-0 top-0 h-full w-full flex animate-flash animate-duration-5s animate-count-infinite select-none items-center justify-center text-1.5rem text-primary-400 font-normal drag-region">
DRAG HERE TO MOVE
</div>
<div class="wall absolute bottom-0 h-8 drag-region" />

View file

@ -3,7 +3,6 @@ import type { ServerChannelQrPayload } from '@proj-airi/stage-shared/server-chan
import { errorMessageFrom } from '@moeru/std'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { Button, Callout, Collapsible } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { renderSVG } from 'uqr'
@ -14,14 +13,7 @@ import { electronGetServerChannelQrPayload } from '../../../../shared/eventa'
import { useServerChannelSettingsStore } from '../../../stores/settings/server-channel'
const { t } = useI18n()
const { trackDevicePairingQrShown } = useAnalytics()
const getServerChannelQrPayload = useElectronEventaInvoke(electronGetServerChannelQrPayload)
function handleToggleExpanded(visible: boolean, setVisible: (value: boolean) => void) {
setVisible(visible)
if (visible)
trackDevicePairingQrShown()
}
const { authToken, hostname, tlsConfig } = storeToRefs(useServerChannelSettingsStore())
const loading = shallowRef(false)
@ -90,7 +82,7 @@ watch([hostname, tlsConfig, authToken], () => {
:class="[
'w-full flex items-center justify-between gap-3 rounded-xl text-left outline-none transition-all duration-250 ease-in-out',
]"
@click="handleToggleExpanded(!slotProps.visible, slotProps.setVisible)"
@click="slotProps.setVisible(!slotProps.visible)"
>
<div :class="['min-w-0 flex flex-col gap-1']">
<div :class="['text-sm font-medium text-neutral-900 dark:text-neutral-100']">

View file

@ -1,48 +1,25 @@
<script setup lang="ts">
import type { DataSettingsStatusEmits } from '@proj-airi/stage-pages/pages/settings/data/status'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { createDataSettingsStatusHelpers } from '@proj-airi/stage-pages/pages/settings/data/status'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { useDataMaintenance } from '@proj-airi/stage-ui/composables/use-data-maintenance'
import { Button, DoubleCheckButton } from '@proj-airi/ui'
import { DoubleCheckButton } from '@proj-airi/ui'
import { useI18n } from 'vue-i18n'
import { electronCenterMainWindow } from '../../../../../shared/eventa'
const emit = defineEmits<DataSettingsStatusEmits>()
const { t } = useI18n()
const { trackDataAction } = useAnalytics()
const { resetDesktopApplicationState } = useDataMaintenance()
const { emitStatus, handleActionError } = createDataSettingsStatusHelpers(emit)
const centerMainWindow = useElectronEventaInvoke(electronCenterMainWindow)
/**
* Clears persisted desktop state from the local application data folder.
*/
async function resetDesktopState() {
try {
await resetDesktopApplicationState()
trackDataAction({ action: 'desktop_state_reset' })
emitStatus(t('settings.pages.data.status.desktop_reset'))
}
catch (error) {
handleActionError(error)
}
}
/**
* Moves the main AIRI desktop window back to the current display center.
*/
async function handleCenterMainWindow() {
try {
await centerMainWindow()
emitStatus(t('settings.pages.data.status.desktop_window_centered'))
}
catch (error) {
handleActionError(error)
}
}
</script>
<template>
@ -57,9 +34,6 @@ async function handleCenterMainWindow() {
</p>
</div>
<div :class="['flex flex-col items-start gap-2']">
<Button variant="caution" icon="i-solar:target-linear" @click="handleCenterMainWindow">
{{ t('settings.pages.data.sections.desktop.center') }}
</Button>
<DoubleCheckButton variant="caution" @confirm="resetDesktopState">
{{ t('settings.pages.data.sections.desktop.reset') }}
<template #confirm>

View file

@ -14,7 +14,6 @@ import type {
import { errorMessageFrom } from '@moeru/std'
import { useElectronEventaContext, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { ModelSettingsPanel } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { Button, Callout } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
@ -273,12 +272,9 @@ async function syncGodotSceneInput(model: DisplayModel) {
}
}
const { trackFeatureUsed } = useAnalytics()
async function handleGodotStageToggle() {
switchingGodotStage.value = true
godotStageError.value = undefined
const enablingGodot = !usesGodotStage.value
try {
if (usesGodotStage.value) {
@ -295,12 +291,6 @@ async function handleGodotStageToggle() {
await refreshGodotStageStatus()
}
finally {
trackFeatureUsed({
feature_name: enablingGodot ? 'godot_stage_enabled' : 'godot_stage_disabled',
business_domain: 'stage_rendering',
entry: 'settings',
success: godotStageError.value === undefined,
})
switchingGodotStage.value = false
}
}

View file

@ -8,7 +8,6 @@ import type { ServerForm } from './mcp-config'
import { errorMessageFrom } from '@moeru/std'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import {
Button,
Callout,
@ -42,7 +41,6 @@ import {
} from './mcp-config'
const { t } = useI18n()
const { trackMcpServerAdded, trackMcpServerRemoved, trackMcpConnectionTestRun } = useAnalytics()
const tn = (key: string, params?: Record<string, unknown>) => t(`settings.pages.modules.mcp-server.${key}`, params ?? {})
const invokeOpenConfigFile = useElectronEventaInvoke(electronMcpOpenConfigFile)
@ -215,17 +213,14 @@ function formatJsonDraft() {
function addServer() {
const server = createServerForm()
servers.value.push(server)
trackMcpServerAdded()
if (!testRowId.value)
testRowId.value = server.rowId
}
function removeServer(rowId: string) {
const i = servers.value.findIndex(s => s.rowId === rowId)
if (i >= 0) {
if (i >= 0)
servers.value.splice(i, 1)
trackMcpServerRemoved()
}
savedIds.value.delete(rowId)
expandedIds.value.delete(rowId)
if (testRowId.value === rowId)
@ -325,7 +320,6 @@ async function runConnectionTest() {
testResult.value = { ok: false, error: errorMessageFrom(e) ?? 'Unknown error', durationMs: 0 }
}
finally {
trackMcpConnectionTestRun({ success: testResult.value?.ok === true })
testRunning.value = false
}
}

View file

@ -3,7 +3,6 @@ import type { ShortcutAccelerator, ShortcutFailureReason } from '@proj-airi/stag
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { formatAccelerator, ShortcutFailureReasons } from '@proj-airi/stage-shared/global-shortcut'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { Button } from '@proj-airi/ui'
import { isMacOS } from 'std-env'
import { computed, onMounted, shallowRef } from 'vue'
@ -18,7 +17,6 @@ import { isSafeSpotlightAccelerator } from '../../../../shared/spotlight-shortcu
const getShortcut = useElectronEventaInvoke(electronSpotlightShortcutGet)
const setShortcut = useElectronEventaInvoke(electronSpotlightShortcutSet)
const { trackSettingsChanged } = useAnalytics()
const { t } = useI18n()
const tt = (key: string) => t(`tamagotchi.settings.pages.system.window-shortcuts.${key}`)
@ -66,13 +64,6 @@ async function saveShortcut(next: ShortcutAccelerator | null) {
return
}
accelerator.value = result.actualAccelerator ?? next ?? accelerator.value
// Value is intentionally coarse (customized / reset) the exact key
// combo is a fingerprinting-adjacent detail no dashboard needs.
trackSettingsChanged({
setting_name: 'spotlight_shortcut',
new_value: next ? 'customized' : 'reset_to_default',
source: 'settings',
})
}
catch {
toast.error(tt('errors.failed'))

View file

@ -1,7 +1,6 @@
<script setup lang="ts">
import { errorMessageFrom } from '@moeru/std'
import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { useWindowFocus } from '@vueuse/core'
import { shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@ -18,7 +17,6 @@ const sending = shallowRef(false)
const inputRef = useTemplateRef<HTMLInputElement>('inputRef')
const chatSyncStore = useChatSyncStore()
const { trackSpotlightUsed } = useAnalytics()
const hideSpotlightWindow = useElectronEventaInvoke(electronSpotlightHide)
const showResultNotification = useElectronEventaInvoke(electronSpotlightShowResultNotification)
const { t } = useI18n()
@ -41,7 +39,6 @@ async function handleSend() {
messageInput.value = ''
sending.value = true
trackSpotlightUsed()
try {
await hideSpotlightWindow()

View file

@ -2,7 +2,6 @@
import type { WidgetsIframeRequestPayload, WidgetsIframeRequestResultPayload, WidgetSnapshot, WidgetWindowSize } from '../../shared/eventa'
import { useElectronEventaContext, useElectronEventaInvoke } from '@proj-airi/electron-vueuse'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { computed, defineAsyncComponent, defineComponent, h, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
@ -101,8 +100,6 @@ async function requestSnapshot(id: string) {
}
}
const { trackWidgetOpened } = useAnalytics()
watch(widgetId, (id) => {
clearTtl()
widget.value = null
@ -110,7 +107,6 @@ watch(widgetId, (id) => {
loading.value = false
if (!id)
return
trackWidgetOpened({ widget_id: id })
requestSnapshot(id)
}, { immediate: true })

View file

@ -1,85 +0,0 @@
import { describe, expect, it } from 'vitest'
import { createVoiceInputInteractionLifecycle } from './voice-input-lifecycle'
/**
* @example
* Voice-input lifecycle calls are serialized across overlapping UI toggles.
*/
describe('createVoiceInputInteractionLifecycle', () => {
// https://github.com/moeru-ai/airi/pull/2004#discussion_r3480058474
// ROOT CAUSE:
//
// If a start begins while an earlier stop is flushing buffered text, the older stop can
// tear down the newly-started microphone consumers after the toggle is already enabled.
//
// Before: start and stop operations ran concurrently.
// After: a start waits for the active stop operation before binding microphone consumers.
/**
* @example
* A start requested during stop waits until stop releases its pending work.
*/
it('serializes a start requested while stop is still in progress', async () => {
const calls: string[] = []
let finishStop!: () => void
const lifecycle = createVoiceInputInteractionLifecycle({
async start() {
calls.push('start')
},
async stop() {
calls.push('stop')
await new Promise<void>((resolve) => {
finishStop = resolve
})
},
})
const stopping = lifecycle.stop()
await Promise.resolve()
const starting = lifecycle.start()
await Promise.resolve()
/**
* @example
* The pending start has not entered the start operation yet.
*/
expect(calls).toEqual(['stop'])
finishStop()
await Promise.all([stopping, starting])
/**
* @example
* The start operation runs only after stop completes.
*/
expect(calls).toEqual(['stop', 'start'])
})
// https://github.com/moeru-ai/airi/pull/2004#discussion_r3480058478
// ROOT CAUSE:
//
// If microphone startup throws, swallowing the error prevents the enabled watcher from
// rolling the persisted toggle back to disabled.
//
// Before: startup failures were reported and then returned as a successful promise.
// After: the lifecycle preserves the rejection for the watcher to handle.
/**
* @example
* A rejected microphone start remains rejected for the enabled watcher.
*/
it('propagates startup failures to its caller', async () => {
const error = new DOMException('Selected microphone is unavailable', 'NotFoundError')
const lifecycle = createVoiceInputInteractionLifecycle({
async start() {
throw error
},
async stop() {},
})
/**
* @example
* Callers can catch the original startup error and roll back UI state.
*/
await expect(lifecycle.start()).rejects.toBe(error)
})
})

View file

@ -1,85 +0,0 @@
/**
* Operations that bind and release the main-stage voice-input consumers.
*
* @param TStopOptions Configuration accepted by the stop operation.
*/
export interface VoiceInputInteractionOperations<TStopOptions> {
/** Starts the streaming or recorder-backed microphone consumers. */
start: () => Promise<void>
/** Stops active microphone consumers and applies the requested flush policy. */
stop: (options?: TStopOptions) => Promise<void>
}
/**
* Serialized lifecycle for main-stage voice input.
*
* Use when:
* - Mic toggles can overlap asynchronous start and stop operations.
* - A restart must wait until the previous listener has fully stopped.
*
* Expects:
* - Operations own the actual microphone, transcription, and transcript-buffer work.
* - Stop remains safe after a failed start.
*
* Returns:
* - Start and stop actions that preserve operation errors and prevent overlapping lifecycles.
*/
export function createVoiceInputInteractionLifecycle<TStopOptions = never>(
operations: VoiceInputInteractionOperations<TStopOptions>,
) {
let startPromise: Promise<void> | undefined
let stopPromise: Promise<void> | undefined
/** Starts after any active stop and deduplicates concurrent starts. */
async function start() {
if (stopPromise)
await stopPromise
if (startPromise)
return startPromise
const operation = Promise.resolve().then(operations.start)
startPromise = operation
try {
await operation
}
finally {
if (startPromise === operation)
startPromise = undefined
}
}
/** Stops after any active start and deduplicates concurrent stops. */
async function stop(options?: TStopOptions) {
if (stopPromise)
return stopPromise
const operation = (async () => {
if (startPromise) {
try {
await startPromise
}
catch {
// A failed start still needs its partially-created microphone consumers released.
}
}
await operations.stop(options)
})()
stopPromise = operation
try {
await operation
}
finally {
if (stopPromise === operation)
stopPromise = undefined
}
}
return {
start,
stop,
isStarting: () => startPromise !== undefined,
isStopping: () => stopPromise !== undefined,
}
}

View file

@ -1,44 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
assistantSpeechCooldownDeadline,
DEFAULT_ASSISTANT_SPEECH_INPUT_COOLDOWN_MS,
shouldSuppressVoiceInput,
} from './voice-input-suppression'
describe('shouldSuppressVoiceInput', () => {
it('suppresses voice input while assistant speech is active', () => {
const result = shouldSuppressVoiceInput({
assistantSpeaking: true,
suppressedUntil: 0,
}, 1000)
expect(result).toBe(true)
})
it('suppresses voice input during the assistant speech cooldown', () => {
const result = shouldSuppressVoiceInput({
assistantSpeaking: false,
suppressedUntil: 1800,
}, 1200)
expect(result).toBe(true)
})
it('allows voice input after assistant speech cooldown ends', () => {
const result = shouldSuppressVoiceInput({
assistantSpeaking: false,
suppressedUntil: 1800,
}, 1800)
expect(result).toBe(false)
})
})
describe('assistantSpeechCooldownDeadline', () => {
it('returns the default cooldown deadline after assistant speech ends', () => {
const result = assistantSpeechCooldownDeadline(1000)
expect(result).toBe(1000 + DEFAULT_ASSISTANT_SPEECH_INPUT_COOLDOWN_MS)
})
})

View file

@ -1,42 +0,0 @@
export const DEFAULT_ASSISTANT_SPEECH_INPUT_COOLDOWN_MS = 800
export interface VoiceInputSuppressionOptions {
assistantSpeaking: boolean
suppressedUntil: number
}
/**
* Decides whether voice input should be ignored while assistant audio can leak into the microphone.
*
* Use when:
* - The assistant is actively playing TTS.
* - The assistant just stopped speaking and speaker echo may still be captured.
*
* Expects:
* - `suppressedUntil` is a timestamp in milliseconds.
*
* Returns:
* - `true` when capture, transcription, and ingestion should be skipped.
*/
export function shouldSuppressVoiceInput(options: VoiceInputSuppressionOptions, now = Date.now()) {
return options.assistantSpeaking || now < options.suppressedUntil
}
/**
* Calculates the timestamp until which voice input should stay muted after assistant speech.
*
* Use when:
* - Assistant playback has ended and the microphone may still receive speaker tail audio.
*
* Expects:
* - `endedAt` is the playback end timestamp in milliseconds.
*
* Returns:
* - A timestamp in milliseconds after the configured cooldown.
*/
export function assistantSpeechCooldownDeadline(
endedAt = Date.now(),
cooldownMs = DEFAULT_ASSISTANT_SPEECH_INPUT_COOLDOWN_MS,
) {
return endedAt + cooldownMs
}

View file

@ -26,7 +26,6 @@ import type {
VrmLoadStartTracePayload,
VrmUpdateFrameTracePayload,
} from '@proj-airi/stage-ui-three/trace'
import type { Rectangle } from 'electron'
import { defineEventa, defineInvokeEventa } from '@moeru/eventa'
@ -34,7 +33,6 @@ export const electronStartTrackMousePosition = defineInvokeEventa('eventa:invoke
export const electronStartDraggingWindow = defineInvokeEventa('eventa:invoke:electron:start-dragging-window')
export const electronOpenMainDevtools = defineInvokeEventa('eventa:invoke:electron:windows:main:devtools:open')
export const electronCenterMainWindow = defineInvokeEventa<Rectangle>('eventa:invoke:electron:windows:main:center')
export const electronOpenSettings = defineInvokeEventa<void, { route?: string }>('eventa:invoke:electron:windows:settings:open')
export const electronSettingsNavigate = defineEventa<{ route: string }>('eventa:event:electron:windows:settings:navigate')
export const electronOpenChat = defineInvokeEventa('eventa:invoke:electron:windows:chat:open')

View file

@ -1,7 +1,6 @@
<script setup lang="ts">
import { OnboardingDialog, OnboardingStepAnalyticsNotice, ToasterRoot } from '@proj-airi/stage-ui/components'
import { useInferencePreload } from '@proj-airi/stage-ui/composables'
import { useAuthProviderSync } from '@proj-airi/stage-ui/composables/use-auth-provider-sync'
import { isPosthogAvailableInBuild, useSharedAnalyticsStore } from '@proj-airi/stage-ui/stores/analytics'
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
@ -24,7 +23,6 @@ import PerformanceOverlay from './components/Devtools/PerformanceOverlay.vue'
import { usePWAStore } from './stores/pwa'
usePWAStore()
useAuthProviderSync()
const contextBridgeStore = useContextBridgeStore()
const i18n = useI18n()

View file

@ -1,6 +1,5 @@
<script setup lang="ts">
import { errorMessageFrom } from '@moeru/std'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { applyOIDCTokens, fetchSession, triggerSignIn } from '@proj-airi/stage-ui/libs/auth'
import { consumeFlowState, exchangeCodeForTokens } from '@proj-airi/stage-ui/libs/auth-oidc'
import { Button } from '@proj-airi/ui'
@ -10,7 +9,6 @@ import { useRouter } from 'vue-router'
const router = useRouter()
const { t } = useI18n()
const { trackOauthCallbackFailed } = useAnalytics()
const error = ref<string | null>(null)
onMounted(async () => {
@ -20,20 +18,17 @@ onMounted(async () => {
const errorParam = url.searchParams.get('error')
if (errorParam) {
trackOauthCallbackFailed({ stage: 'provider_error' })
error.value = url.searchParams.get('error_description') ?? errorParam
return
}
if (!code || !state) {
trackOauthCallbackFailed({ stage: 'missing_code_or_state' })
error.value = t('server.auth.webCallback.message.missingCodeOrState')
return
}
const persisted = consumeFlowState()
if (!persisted) {
trackOauthCallbackFailed({ stage: 'missing_flow_state' })
error.value = t('server.auth.webCallback.message.missingFlowState')
return
}
@ -45,7 +40,6 @@ onMounted(async () => {
router.replace('/')
}
catch (err) {
trackOauthCallbackFailed({ stage: 'token_exchange_failed' })
error.value = errorMessageFrom(err) ?? t('server.auth.webCallback.message.tokenExchangeFailed')
}
})

View file

@ -27,7 +27,7 @@ const emit = defineEmits<{
}>()
const characterStore = useCharacterStore()
const { trackCharacterCreated, trackCharacterUpdated } = useAnalytics()
const { trackCharacterCreated } = useAnalytics()
// Form State
const form = reactive({
@ -154,7 +154,6 @@ async function handleSubmit() {
version: form.version,
coverUrl: form.coverUrl,
})
trackCharacterUpdated({ character_id: props.character.id })
// Capabilities/I18n update not supported in simple UpdateCharacterSchema yet?
// Checking types/character.ts: UpdateCharacterSchema only has version, coverUrl, characterId.
// So deep update is not supported by the simple endpoint yet?

View file

@ -1,7 +1,6 @@
<script setup lang="ts">
import type { Character } from '@proj-airi/stage-ui/types/character'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { useCharacterStore } from '@proj-airi/stage-ui/stores/characters'
import { Button, FieldInput } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
@ -10,7 +9,6 @@ import { computed, onMounted, ref } from 'vue'
import CharacterDialog from './components/CharacterDialog.vue'
import CharacterItem from './components/CharacterItem.vue'
const { trackCharacterDeleted } = useAnalytics()
const characterStore = useCharacterStore()
const { characters } = storeToRefs(characterStore)
@ -47,9 +45,7 @@ function handleDelete(id: string) {
// TODO: Remove this
// eslint-disable-next-line no-alert
if (confirm('Are you sure you want to delete this character?')) {
characterStore.remove(id)
.then(() => trackCharacterDeleted({ character_id: id }))
.catch(console.error)
characterStore.remove(id).catch(console.error)
}
}

View file

@ -14,7 +14,6 @@ import { routes } from 'vue-router/auto-routes'
import App from './App.vue'
import { initAuthAnalytics } from './modules/analytics'
import { AUTH_UI_ROUTER_BASE_PATH } from './modules/auth-ui-base'
import { i18n } from './modules/i18n'
@ -24,8 +23,6 @@ import 'vue-sonner/style.css'
import './styles/main.css'
import 'uno.css'
initAuthAnalytics()
const pinia = createPinia()
// TODO: vite-plugin-vue-layouts is long deprecated, replace with another layout solution

View file

@ -1,48 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { initAuthAnalytics, trackSignupFormCompleted } from './analytics'
const posthogMocks = vi.hoisted(() => ({
capture: vi.fn(),
init: vi.fn(),
register: vi.fn(),
}))
vi.mock('posthog-js', () => ({
default: posthogMocks,
}))
vi.mock('../../../../posthog.config', () => ({
DEFAULT_POSTHOG_CONFIG: {},
POSTHOG_ENABLED: true,
POSTHOG_PROJECT_KEY: 'test-project-key',
}))
describe('auth product analytics', () => {
beforeEach(() => {
posthogMocks.capture.mockClear()
posthogMocks.init.mockClear()
posthogMocks.register.mockClear()
})
// ROOT CAUSE:
//
// The auth SPA emitted `signup_completed` before it knew the Better Auth
// user id, while the server emitted the same canonical event with that id.
// PostHog therefore counted one email signup as two unrelated persons.
//
// The anonymous UI milestone must use its own name. The identified server
// event remains the only canonical `signup_completed` business fact.
it('keeps anonymous signup UI completion separate from the canonical server signup fact', () => {
expect(initAuthAnalytics()).toBe(true)
expect(posthogMocks.register).toHaveBeenCalledWith({ app_surface: 'auth' })
trackSignupFormCompleted({ source: 'email', requires_verification: true })
expect(posthogMocks.capture).toHaveBeenCalledWith(
'signup_form_completed',
{ source: 'email', requires_verification: true },
undefined,
)
})
})

View file

@ -1,166 +0,0 @@
/**
* PostHog product analytics for the auth-only SPA (`apps/ui-server-auth`).
*
* This surface captures anonymous auth-UI milestones such as form completion,
* sign-in attempts, email verification, and password recovery. Canonical
* registration facts come from the server as identified `signup_completed`
* events; the auth SPA intentionally uses `signup_form_completed` so an event
* emitted before {@link identifyAuthUser} cannot double-count a new user.
*
* Unlike the stage apps there is no in-app analytics consent toggle here
* (the user isn't signed in yet, so there's no settings store to read).
* Capture posture matches the docs site: enabled in analytics-enabled
* builds (`VITE_ENABLE_POSTHOG`), disclosed via the privacy policy linked
* on the sign-in page.
*/
import type { OauthCallbackFailureStage } from '@proj-airi/stage-ui/composables'
import posthog from 'posthog-js'
import {
DEFAULT_POSTHOG_CONFIG,
POSTHOG_ENABLED,
POSTHOG_PROJECT_KEY,
} from '../../../../posthog.config'
/** Login/signup credential kinds shown on the sign-in page. */
export type AuthMethod = 'email' | 'github' | 'google'
let initialized = false
/**
* Initialize PostHog for the auth surface. Call once from `main.ts` before
* mount; later calls are no-ops. Returns whether capture is active so
* callers can skip building event payloads in analytics-disabled builds.
*/
export function initAuthAnalytics(): boolean {
if (!POSTHOG_ENABLED)
return false
if (initialized)
return true
posthog.init(POSTHOG_PROJECT_KEY, { ...DEFAULT_POSTHOG_CONFIG })
// Same single-project setup as the stage apps: the `app_surface` super
// property is how auth traffic is told apart in shared dashboards.
posthog.register({ app_surface: 'auth' })
initialized = true
return true
}
/**
* Merge this browser's anonymous events with the Better Auth user person.
* `userId` must be the Better Auth `user.id` the same value the server
* uses as `distinctId` (see `apps/server` product events forwarding).
*/
export function identifyAuthUser(userId: string): void {
if (!initialized)
return
posthog.identify(userId)
}
interface CaptureOptions {
/**
* Set when navigation immediately follows the capture call
* (`window.location.href = ...`). The batched queue would race the
* unload and drop the event; sendBeacon survives it.
*/
beforeNavigation?: boolean
}
function capture(event: string, properties: Record<string, unknown>, options?: CaptureOptions): void {
if (!initialized)
return
posthog.capture(
event,
properties,
options?.beforeNavigation ? { send_instantly: true, transport: 'sendBeacon' } : undefined,
)
}
/** Anonymous email-signup UI milestone; the server owns the registration fact. */
export function trackSignupFormCompleted(properties: { source: AuthMethod, requires_verification: boolean }): void {
capture('signup_form_completed', properties, { beforeNavigation: !properties.requires_verification })
}
/**
* OAuth flows leave the page before their outcome is knowable, so the
* client can only record the attempt; completion shows up as the
* identified session on the callback landing.
*/
export function trackLoginStarted(properties: { method: AuthMethod }): void {
capture('login_started', properties, { beforeNavigation: true })
}
/** Credential sign-in succeeded; OIDC continuation navigation follows. */
export function trackLoginSucceeded(properties: { method: AuthMethod }): void {
capture('login_succeeded', properties, { beforeNavigation: true })
}
/**
* Sign-in attempt failed. No error detail on purpose auth error messages
* can embed the email address, and the count per method is what the funnel
* needs.
*/
export function trackLoginFailed(properties: { method: AuthMethod }): void {
capture('login_failed', properties)
}
/** Verification link landing with `?verified=true`. */
export function trackEmailVerificationCompleted(): void {
capture('email_verification_completed', {})
}
/** Verification link landing with `?error=...`. */
export function trackEmailVerificationFailed(): void {
capture('email_verification_failed', {})
}
export function trackPasswordResetRequested(): void {
capture('password_reset_requested', {})
}
export function trackPasswordResetCompleted(): void {
capture('password_reset_completed', {})
}
export function trackPasswordChanged(): void {
capture('password_changed', {})
}
/**
* Link handed off to the provider's consent page. Completion is not
* client-observable (it lands back via a full-page OAuth redirect), so the
* funnel pairs this with the refreshed linked-accounts state server-side.
*/
export function trackOauthProviderLinkStarted(properties: { provider: string }): void {
capture('oauth_provider_link_started', properties, { beforeNavigation: true })
}
export function trackOauthProviderUnlinked(properties: { provider: string }): void {
capture('oauth_provider_unlinked', properties)
}
/**
* Deletion-confirmed landing page reached (`delete-account.vue`). The
* deletion request itself is raised from the stage apps' account settings.
*/
export function trackAccountDeletionCompleted(): void {
capture('account_deletion_completed', {})
}
export function trackSignedOut(): void {
capture('signed_out', {})
}
/**
* Electron OIDC relay handoff failed. `stage` distinguishes a malformed
* callback (`parse`) from an unreachable local app (`relay_unreachable`);
* the full cross-surface vocabulary lives in stage-ui's
* `OauthCallbackFailureStage` so the two emitters share one schema.
*/
export function trackOauthCallbackFailed(properties: { stage: Extract<OauthCallbackFailureStage, 'parse' | 'relay_unreachable'> }): void {
capture('oauth_callback_failed', properties)
}

View file

@ -15,18 +15,6 @@ describe('ui-server-auth bootstrap context', () => {
})
})
it('uses the trusted new Go backend origin carried by standalone server redirects', () => {
const currentUrl = 'https://auth.airi.build/ui/sign-in?api_server_url=https%3A%2F%2Fairi-server-next.up.railway.app&client_id=airi-stage-pocket'
expect(resolveStandaloneServerAuthContext(
currentUrl,
'https://api.airi.build',
)).toEqual({
apiServerUrl: 'https://airi-server-next.up.railway.app',
currentUrl,
})
})
it('ignores untrusted API server origins from crafted standalone auth URLs', () => {
expect(resolveStandaloneServerAuthContext(
'https://accounts.airi.build/ui/sign-in?api_server_url=https%3A%2F%2Fevil.example&client_id=airi-stage-web',

View file

@ -17,7 +17,6 @@ const API_SERVER_URL_QUERY_PARAM = 'api_server_url'
const TRUSTED_STANDALONE_API_SERVER_ORIGINS = [
'https://api.airi.build',
'https://airi-server-dev.up.railway.app',
'https://airi-server-next.up.railway.app',
]
const TRUSTED_HTTPS_API_SERVER_HOSTS = new Map(

View file

@ -1,10 +1,8 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { trackAccountDeletionCompleted } from '../modules/analytics'
// NOTICE:
// This page is a SUCCESS landing better-auth's `/api/auth/delete-user/callback`
// performs the actual deletion server-side, then redirects here via the
@ -30,14 +28,6 @@ const errorMessage = computed(() => {
const value = route.query.error
return typeof value === 'string' ? value : null
})
onMounted(() => {
// Strongest churn fact the client can observe: better-auth only
// redirects here after the deletion callback already ran server-side,
// so rendering the success state means the account is gone.
if (!errorMessage.value)
trackAccountDeletionCompleted()
})
</script>
<template>

View file

@ -4,7 +4,6 @@ import { onMounted, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { parseElectronCallbackQuery } from '../composables/electron-callback.shared'
import { trackOauthCallbackFailed } from '../modules/analytics'
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
type CallbackStatus = 'loading' | 'success' | 'fallback' | 'error'
@ -56,7 +55,6 @@ async function runRelayFlow() {
const parsed = parseElectronCallbackQuery(query)
if (parsed.status === 'error') {
trackOauthCallbackFailed({ stage: 'parse' })
setViewModel({
description: t('server.auth.electronCallback.message.invalidResponse'),
detail: parsed.message,
@ -100,7 +98,6 @@ async function runRelayFlow() {
}, 1200)
}
catch {
trackOauthCallbackFailed({ stage: 'relay_unreachable' })
setViewModel({
description: t('server.auth.electronCallback.message.loopbackUnreachable'),
detail: t('server.auth.electronCallback.message.tryOpenDirectly'),

View file

@ -4,7 +4,6 @@ import { Button, FieldInput } from '@proj-airi/ui'
import { reactive, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { trackPasswordResetRequested } from '../modules/analytics'
import { buildCurrentOriginAuthUiUrl } from '../modules/auth-ui-base'
import { describeAuthError, requestPasswordReset } from '../modules/email-password'
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
@ -37,7 +36,6 @@ async function handleSubmit(event: Event) {
email: form.email.trim(),
redirectTo: resetRedirect,
})
trackPasswordResetRequested()
submitted.value = true
}
catch (error) {

View file

@ -9,14 +9,6 @@ import { computed, onMounted, reactive, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import {
identifyAuthUser,
trackOauthProviderLinkStarted,
trackOauthProviderUnlinked,
trackPasswordChanged,
trackPasswordResetRequested,
trackSignedOut,
} from '../modules/analytics'
import { getAuthClient } from '../modules/auth-client'
import { requestPasswordReset } from '../modules/email-password'
import {
@ -106,8 +98,6 @@ const {
unlinked: provider => t('server.auth.profile.linkedAccounts.message.unlinked', { provider }),
linkStarted: provider => t('server.auth.profile.linkedAccounts.message.linkStarted', { provider }),
},
onUnlinked: providerId => trackOauthProviderUnlinked({ provider: providerId }),
onLinkStarted: providerId => trackOauthProviderLinkStarted({ provider: providerId }),
})
const nameDirty = computed(() => {
@ -148,9 +138,6 @@ onMounted(async () => {
// call needed here.
user.value = result.user
profileForm.name = result.user.name
// Merge this browser's anonymous funnel events (sign-in page views,
// login_started, ) into the Better Auth user person.
identifyAuthUser(result.user.id)
}
catch (error) {
profileError.value = describeProfileError(error) || t('server.auth.profile.error.loadFailed')
@ -213,7 +200,6 @@ async function handleChangePassword(event: Event) {
passwordForm.next = ''
passwordForm.confirm = ''
passwordSuccess.value = t('server.auth.profile.message.passwordChanged')
trackPasswordChanged()
}
catch (error) {
passwordError.value = describeProfileError(error) || t('server.auth.profile.error.changePasswordFailed')
@ -251,7 +237,6 @@ async function handleSendSetPasswordLink() {
redirectTo: new URL('/auth/reset-password', apiServerUrl).toString(),
})
setPasswordSuccess.value = t('server.auth.profile.password.setLinkSent', { email: user.value.email })
trackPasswordResetRequested()
}
catch (error) {
setPasswordError.value = describeProfileError(error)
@ -271,7 +256,6 @@ async function handleSignOut() {
try {
await signOut({ apiServerUrl })
trackSignedOut()
await router.replace('/sign-in')
}
catch (error) {

View file

@ -5,7 +5,6 @@ import { computed, reactive, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { trackPasswordResetCompleted } from '../modules/analytics'
import { describeAuthError, resetPasswordWithToken } from '../modules/email-password'
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
@ -53,7 +52,6 @@ async function handleSubmit(event: Event) {
newPassword: form.password,
token: token.value,
})
trackPasswordResetCompleted()
completed.value = true
}
catch (error) {

View file

@ -8,12 +8,6 @@ import { computed, reactive, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import {
trackLoginFailed,
trackLoginStarted,
trackLoginSucceeded,
trackSignupFormCompleted,
} from '../modules/analytics'
import { buildCurrentOriginAuthUiUrl } from '../modules/auth-ui-base'
import {
checkEmail,
@ -133,11 +127,9 @@ async function handleProviderSelect(provider: OAuthProvider) {
callbackURL: effectiveCallbackURL.value,
})
trackLoginStarted({ method: provider })
window.location.href = redirectUrl
}
catch (error) {
trackLoginFailed({ method: provider })
errorMessage.value = describeAuthError(error) || t('server.auth.signIn.error.fallback')
pendingProvider.value = null
}
@ -210,11 +202,9 @@ async function handleEmailSignIn(event: Event) {
// After a successful credential sign-in better-auth has set the session
// cookie. Bounce into the OIDC `/oauth2/authorize` flow (or wherever the
// OIDC client originally pointed) so the upstream stage app gets its tokens.
trackLoginSucceeded({ method: 'email' })
window.location.href = result.redirectURL ?? effectiveCallbackURL.value
}
catch (error) {
trackLoginFailed({ method: 'email' })
errorMessage.value = describeAuthError(error) || t('server.auth.signIn.error.fallback')
}
finally {
@ -247,7 +237,6 @@ async function handleEmailSignUp(event: Event) {
})
if (result.requiresVerification) {
trackSignupFormCompleted({ source: 'email', requires_verification: true })
await router.push({
path: '/verify-email',
query: {
@ -260,7 +249,6 @@ async function handleEmailSignUp(event: Event) {
// Verification disabled at server config: session is live, fall through
// to the OIDC continuation just like sign-in.
trackSignupFormCompleted({ source: 'email', requires_verification: false })
window.location.href = effectiveCallbackURL.value
}
catch (error) {

View file

@ -5,7 +5,6 @@ import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { trackEmailVerificationCompleted, trackEmailVerificationFailed } from '../modules/analytics'
import { buildCurrentOriginAuthUiUrl } from '../modules/auth-ui-base'
import { getServerAuthBootstrapContext } from '../modules/server-auth-context'
@ -88,16 +87,13 @@ onMounted(async () => {
// session cookie has been written, then stay put so the user sees the
// success message. The pending tab does the OIDC continuation.
if (verified.value) {
trackEmailVerificationCompleted()
if (isSupported.value)
post('verified')
return
}
if (error.value) {
trackEmailVerificationFailed()
if (error.value)
return
}
// Pending tab: cover the case where verification already happened before
// this tab subscribed (back-button navigation, page reload, etc.). One

View file

@ -8,5 +8,5 @@ if (!import.meta.env.DEV) {
})
// Tag docs-site traffic so it can be told apart from the app surfaces
// inside the shared project.
posthog.register({ app_surface: 'docs' })
posthog.register({ surface: 'docs' })
}

View file

@ -19640,7 +19640,7 @@
}
},
"isOneTime": false,
"monthlyDollars": -1,
"monthlyDollars": 8,
"privacyLevel": "PUBLIC",
"tierName": "Patreon",
"createdAt": "2026-04-03T08:34:28.264+00:00",

View file

@ -1,7 +1,7 @@
{
"name": "@proj-airi/docs",
"type": "module",
"version": "0.11.1",
"version": "0.11.0",
"private": true,
"scripts": {
"build": "vitepress build",

View file

@ -1,204 +0,0 @@
# Auth UI New Backend Origin Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the production Auth UI honor OIDC redirects from `https://airi-server-next.up.railway.app` without broadening trust to arbitrary Railway origins.
**Architecture:** Keep the existing exact-origin trust model in `server-auth-context.ts`. Add one exact production origin and prove the redirect context resolves to it while existing untrusted-origin tests remain green.
**Tech Stack:** TypeScript, Vue/Vite, Vitest, pnpm, GitHub Actions, Cloudflare Pages
---
### Task 1: Trust the new Go backend origin
**Files:**
- Modify: `apps/ui-server-auth/src/modules/server-auth-context.test.ts:18`
- Modify: `apps/ui-server-auth/src/modules/server-auth-context.ts:17`
- [ ] **Step 1: Write the failing regression test**
Add this case after the existing trusted server-dev case:
```ts
it('uses the trusted new Go backend origin carried by standalone server redirects', () => {
const currentUrl = 'https://auth.airi.build/ui/sign-in?api_server_url=https%3A%2F%2Fairi-server-next.up.railway.app&client_id=airi-stage-pocket'
expect(resolveStandaloneServerAuthContext(
currentUrl,
'https://api.airi.build',
)).toEqual({
apiServerUrl: 'https://airi-server-next.up.railway.app',
currentUrl,
})
})
```
- [ ] **Step 2: Run the focused test and verify RED**
Run:
```bash
pnpm -F @proj-airi/ui-server-auth exec vitest run src/modules/server-auth-context.test.ts
```
Expected: one assertion fails because `resolveStandaloneServerAuthContext` returns `null` for the new backend origin.
- [ ] **Step 3: Add the exact trusted production origin**
Update the allowlist to:
```ts
const TRUSTED_STANDALONE_API_SERVER_ORIGINS = [
'https://api.airi.build',
'https://airi-server-next.up.railway.app',
'https://airi-server-dev.up.railway.app',
]
```
- [ ] **Step 4: Run the focused test and verify GREEN**
Run:
```bash
pnpm -F @proj-airi/ui-server-auth exec vitest run src/modules/server-auth-context.test.ts
```
Expected: the file passes with six tests, including the existing crafted-origin rejection.
- [ ] **Step 5: Commit the behavior change**
```bash
git add apps/ui-server-auth/src/modules/server-auth-context.ts apps/ui-server-auth/src/modules/server-auth-context.test.ts
git commit -m "fix(ui-server-auth): trust new Go backend origin"
```
### Task 2: Verify the Auth UI package
**Files:**
- Verify: `apps/ui-server-auth/**`
- [ ] **Step 1: Run the complete Auth UI test suite**
```bash
pnpm -F @proj-airi/ui-server-auth exec vitest run
```
Expected: all Auth UI test files pass.
- [ ] **Step 2: Run type checking**
```bash
pnpm -F @proj-airi/ui-server-auth typecheck
```
Expected: exit code 0 with no TypeScript errors.
- [ ] **Step 3: Run the production build**
```bash
VITE_SERVER_URL=https://api.airi.build pnpm -F @proj-airi/ui-server-auth build
```
Expected: Vite exits with code 0 and writes `apps/ui-server-auth/dist`.
- [ ] **Step 4: Inspect the built bundle**
```bash
rg -l 'airi-server-next\.up\.railway\.app' apps/ui-server-auth/dist/assets/*.js
```
Expected: at least one emitted JavaScript asset contains the exact new backend origin.
### Task 3: Publish, deploy, and validate production
**Files:**
- Verify: `.github/workflows/deploy-cloudflare-auth-ui.yml`
- [ ] **Step 1: Push the feature branch and open a pull request**
```bash
git push -u origin codex/auth-ui-new-backend
PR_URL="$(gh pr create --repo moeru-ai/airi --base main --head Neko-233:codex/auth-ui-new-backend --title "fix(ui-server-auth): trust new Go backend origin" --body $'## Summary\n- trust the new Railway Go backend in the standalone Auth UI\n- preserve exact-origin validation for untrusted redirects\n\n## Test plan\n- focused and complete Auth UI Vitest suites\n- Auth UI typecheck and production build\n- emitted bundle origin check')"
printf '%s' "$PR_URL" > /tmp/auth-ui-new-backend-pr-url
printf '%s\n' "$PR_URL"
```
Expected: GitHub returns a pull request URL targeting `moeru-ai/airi:main`.
- [ ] **Step 2: Merge after required checks pass**
```bash
PR_URL="$(cat /tmp/auth-ui-new-backend-pr-url)"
gh pr checks "$PR_URL" --watch
gh pr merge "$PR_URL" --squash --delete-branch
```
Expected: the pull request is merged into `main`.
- [ ] **Step 3: Wait for the production Auth UI deployment**
```bash
RUN_ID="$(gh run list --repo moeru-ai/airi --workflow deploy-cloudflare-auth-ui.yml --branch main --limit 1 --json databaseId --jq '.[0].databaseId')"
gh run watch --repo moeru-ai/airi "$RUN_ID"
```
Expected: `Cloudflare Pages (Auth UI)` completes successfully for the merge commit.
- [ ] **Step 4: Validate the deployed bundle and login boundary**
Fetch the current Auth UI HTML, resolve its hashed JavaScript assets, and verify one asset contains the exact new backend origin:
```bash
LIVE_DIR="$(mktemp -d /tmp/airi-auth-ui-live.XXXXXX)"
printf '%s' "$LIVE_DIR" > /tmp/airi-auth-ui-live-dir
mkdir -p "$LIVE_DIR/assets"
curl -fsSL https://auth.airi.build/ui/sign-in -o "$LIVE_DIR/index.html"
rg -o 'src="/assets/[^"]+\.js' "$LIVE_DIR/index.html" | sed 's/^src="//' | while read -r asset; do
curl -fsSL "https://auth.airi.build${asset}" -o "$LIVE_DIR/assets/$(basename "$asset")"
done
rg -l 'https://airi-server-next\.up\.railway\.app' "$LIVE_DIR"/assets/*.js
```
Repeat the new backend OIDC authorize redirect and confirm the final Auth UI location carries the new `api_server_url`:
```bash
LIVE_DIR="$(cat /tmp/airi-auth-ui-live-dir)"
curl -sS -L -o /dev/null -D "$LIVE_DIR/redirects.headers" -G 'https://airi-server-next.up.railway.app/api/auth/oauth2/authorize' \
--data-urlencode 'response_type=code' \
--data-urlencode 'client_id=airi-stage-pocket' \
--data-urlencode 'redirect_uri=ai.moeru.airi-pocket://links/auth/callback' \
--data-urlencode 'scope=openid profile email offline_access' \
--data-urlencode 'state=production-validation' \
--data-urlencode 'code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' \
--data-urlencode 'code_challenge_method=S256' \
--data-urlencode 'resource=https://airi-server-next.up.railway.app'
rg -n 'location: https://auth\.airi\.build/.*api_server_url=https%3A%2F%2Fairi-server-next\.up\.railway\.app' -i "$LIVE_DIR/redirects.headers"
```
Send an OPTIONS preflight and a diagnostic POST to:
```text
https://airi-server-next.up.railway.app/api/auth/check-email
```
Run:
```bash
LIVE_DIR="$(cat /tmp/airi-auth-ui-live-dir)"
curl -sS -o /dev/null -D "$LIVE_DIR/preflight.headers" \
-X OPTIONS 'https://airi-server-next.up.railway.app/api/auth/check-email' \
-H 'Origin: https://auth.airi.build' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type'
rg -n 'HTTP/.* 204|access-control-allow-origin: https://auth\.airi\.build' -i "$LIVE_DIR/preflight.headers"
curl -sS -o "$LIVE_DIR/check-email.json" -w '%{http_code}\n' \
-X POST 'https://airi-server-next.up.railway.app/api/auth/check-email' \
-H 'Origin: https://auth.airi.build' \
-H 'Content-Type: application/json' \
--data '{"email":"codex-production-validation@example.com"}'
jq -e '.exists == false and .hasPassword == false' "$LIVE_DIR/check-email.json"
```
Expected: preflight returns `204` with `access-control-allow-origin: https://auth.airi.build`, and the diagnostic lookup returns `200` with a valid response body.

View file

@ -1,38 +0,0 @@
# Auth UI New Backend Origin Design
## Problem
The production Auth UI receives `api_server_url=https://airi-server-next.up.railway.app` from the new Go backend, but its standalone bootstrap allowlist does not recognize that origin. The Auth UI therefore falls back to `https://api.airi.build`. Browser requests then fail at the cross-origin boundary and the sign-in page reports `Load failed`.
The new Go backend itself is healthy: its email lookup endpoint returns successfully and explicitly allows the production Auth UI origin.
## Scope
This change is limited to `apps/ui-server-auth`:
- Trust the exact production origin `https://airi-server-next.up.railway.app`.
- Preserve the existing trust entries for the original production backend, server-dev, and localhost development.
- Preserve rejection of arbitrary external origins.
The change must not add wildcard Railway trust, alter iOS login behavior, or change backend CORS policy.
## Implementation
Add the new Go backend origin to `TRUSTED_STANDALONE_API_SERVER_ORIGINS` in `server-auth-context.ts`.
Add a focused regression test that passes a standalone Auth UI URL containing the new backend in `api_server_url` and expects the normalized new backend origin. Existing untrusted-origin coverage remains the security regression guard.
## Verification
Before implementation, the new regression test must fail because the new origin is not trusted. After the minimal allowlist change:
- Run the focused bootstrap-context tests.
- Run the complete Auth UI test suite.
- Run Auth UI type checking and production build.
- Confirm the production bundle contains the new trusted origin after deployment.
- Confirm the live OIDC redirect carries the new backend and the Auth UI selects it.
- Confirm the browser CORS preflight and email lookup succeed against the new backend.
## Delivery
Develop on an isolated branch based on the latest `upstream/main`. Push the branch, open a pull request, and merge only after checks pass. The existing `Cloudflare Pages (Auth UI)` workflow deploys production on pushes to `main`. After deployment, validate the live login boundary before considering the issue resolved.

View file

@ -1,7 +1,7 @@
{
"name": "@proj-airi/stage-tamagotchi-godot",
"type": "module",
"version": "0.11.1",
"version": "0.11.0",
"private": true,
"description": "Godot-native desktop stage runtime workspace engine for stage-tamagotchi",
"author": {

6
flake.lock generated
View file

@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1783224372,
"narHash": "sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU+K27sk=",
"lastModified": 1780243769,
"narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d407951447dcd00442e97087bf374aad70c04cea",
"rev": "331800de5053fcebacf6813adb5db9c9dca22a0c",
"type": "github"
},
"original": {

View file

@ -72,17 +72,16 @@
at-spi2-core
cups.lib # libcups.so.2 is in 'lib' output, not 'out'
libdrm
libx11
libxcomposite
libxdamage
libxext
libxfixes
libxrandr
libxcb
libxcursor
libxi
libxt
libxtst
xorg.libX11
xorg.libXcomposite
xorg.libXdamage
xorg.libXext
xorg.libXfixes
xorg.libXrandr
xorg.libxcb
xorg.libXcursor
xorg.libXi
xorg.libXtst
expat
libxkbcommon
libgbm # libgbm.so.1 is now a separate package from mesa

View file

@ -1,7 +1,7 @@
{
"name": "@proj-airi/airi-plugin-vscode",
"type": "module",
"version": "0.11.1",
"version": "0.11.0",
"private": true,
"description": "AIRI host plugin that bridges VS Code context and UI",
"repository": {

View file

@ -3,7 +3,7 @@
"name": "vscode-airi",
"displayName": "AIRI VSCode adapter",
"type": "module",
"version": "0.11.1",
"version": "0.11.0",
"private": true,
"description": "Adapter that connects VSCode into Project AIRI",
"repository": {

View file

@ -1,7 +1,7 @@
{
"name": "@proj-airi/root",
"type": "module",
"version": "0.11.1",
"version": "0.11.0",
"private": true,
"packageManager": "pnpm@10.33.0",
"description": "LLM powered virtual character",

View file

@ -1,7 +1,7 @@
{
"name": "@proj-airi/airi-screenshot",
"type": "module",
"version": "0.11.1",
"version": "0.11.0",
"private": true,
"description": "AIRI monorepo screenshot orchestration CLI",
"author": {

View file

@ -1,7 +1,7 @@
{
"name": "@proj-airi/better-ws",
"type": "module",
"version": "0.11.1",
"version": "0.11.0",
"private": true,
"description": "Transport-agnostic reliable WebSocket runtime primitives",
"author": {

View file

@ -1,7 +1,7 @@
{
"name": "@proj-airi/cap-vite",
"type": "module",
"version": "0.11.1",
"version": "0.11.0",
"description": "CLI that starts a Vite dev server and runs Capacitor live reload",
"author": {
"name": "Moeru AI Project AIRI Team",

View file

@ -1,7 +1,7 @@
{
"name": "@proj-airi/core-agent",
"type": "module",
"version": "0.11.1",
"version": "0.11.0",
"private": true,
"description": "Core agent runtime orchestration for AIRI",
"author": {

View file

@ -2,7 +2,7 @@ import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { Message } from '@xsai/shared-chat'
import type { ChatHistoryItem, ContextMessage, StreamingAssistantMessage } from '../types/chat'
import type { StreamEvent, StreamOptions } from '../types/llm'
import type { StreamEvent } from '../types/llm'
import { ContextUpdateStrategy } from '@proj-airi/server-shared/types'
import { describe, expect, it, vi } from 'vitest'
@ -42,11 +42,11 @@ function createHarness() {
llmRequestStarted: [] as unknown[],
llmFirstToken: [] as unknown[],
assistantResponseRendered: [] as unknown[],
llmGeneration: [] as unknown[],
messageRound: [] as unknown[],
messageRoundFailed: [] as unknown[],
}
const stream = vi.fn(async (_model: string, _chatProvider: ChatProvider, _messages: Message[], options?: StreamOptions) => {
const stream = vi.fn(async (_model: string, _chatProvider: ChatProvider, _messages: Message[], options?: {
onStreamEvent?: (event: StreamEvent) => Promise<void> | void
}) => {
await options?.onStreamEvent?.({ type: 'text-delta', text: 'assistant reply' })
await options?.onStreamEvent?.({ type: 'finish', finishReason: 'stop' })
})
@ -99,9 +99,7 @@ function createHarness() {
onLlmRequestStarted: event => telemetry.llmRequestStarted.push(event),
onLlmFirstToken: event => telemetry.llmFirstToken.push(event),
onAssistantResponseRendered: event => telemetry.assistantResponseRendered.push(event),
onLlmGeneration: event => telemetry.llmGeneration.push(event),
onMessageRound: event => telemetry.messageRound.push(event),
onMessageRoundFailed: event => telemetry.messageRoundFailed.push(event),
})
return {
@ -297,16 +295,6 @@ describe('createChatOrchestratorRuntime', () => {
it('emits telemetry milestones for a successful voice-backed message round', async () => {
const harness = createHarness()
harness.monotonicNow.set([100, 150, 250, 400, 460])
harness.stream.mockImplementationOnce(async (_model, _chatProvider, _messages, options) => {
await options?.onStreamEvent?.({ type: 'text-delta', text: 'assistant reply' })
await options?.onStreamEvent?.({ type: 'finish', finishReason: 'stop' })
await options?.onUsage?.({
inputTokens: 12,
outputTokens: 8,
totalTokens: 20,
source: 'reported',
})
})
await harness.runtime.ingest('hello from voice', {
model: 'gpt-test',
@ -320,101 +308,42 @@ describe('createChatOrchestratorRuntime', () => {
})
expect(harness.telemetry.messageSendStarted).toEqual([{
conversationId: 'session-1',
roundId: 'user-id',
source: 'voice',
model: 'gpt-test',
turnIndex: 1,
}])
expect(harness.telemetry.llmRequestStarted).toEqual([{
conversationId: 'session-1',
roundId: 'user-id',
model: 'gpt-test',
provider: 'mock-provider',
hasVoice: true,
turnIndex: 1,
}])
expect(harness.telemetry.llmFirstToken).toEqual([{
conversationId: 'session-1',
roundId: 'user-id',
model: 'gpt-test',
ttfbMs: 100,
turnIndex: 1,
}])
expect(harness.telemetry.assistantResponseRendered).toEqual([{
conversationId: 'session-1',
roundId: 'user-id',
model: 'gpt-test',
latencyMs: 250,
turnIndex: 1,
}])
expect(harness.telemetry.llmGeneration).toEqual([{
conversationId: 'session-1',
roundId: 'user-id',
model: 'gpt-test',
provider: 'mock-provider',
inputTokens: 12,
outputTokens: 8,
totalTokens: 20,
usageSource: 'reported',
turnIndex: 1,
}])
expect(harness.telemetry.messageRound).toEqual([{
conversationId: 'session-1',
roundId: 'user-id',
durationMs: 360,
hasVoice: true,
inputTokens: 12,
model: 'gpt-test',
outputTokens: 8,
totalTokens: 20,
turnIndex: 1,
usageSource: 'reported',
}])
expect(harness.telemetry.chatActivationStarted).toEqual([{
conversationId: 'session-1',
model: 'gpt-test',
provider: 'mock-provider',
roundId: 'user-id',
sessionId: 'session-1',
source: 'voice',
turnIndex: 1,
}])
expect(harness.telemetry.chatActivationSucceeded).toEqual([{
conversationId: 'session-1',
durationMs: 360,
model: 'gpt-test',
provider: 'mock-provider',
roundId: 'user-id',
source: 'voice',
turnIndex: 1,
}])
expect(harness.telemetry.chatActivationFailed).toEqual([])
})
// ROOT CAUSE:
//
// Activation callbacks were emitted for every chat round, so production
// `chat_activation_*` volume tracked message traffic instead of the first
// successful assistant response in a conversation.
it('emits activation milestones only until the conversation gets its first assistant response', async () => {
const harness = createHarness()
await harness.runtime.ingest('first turn', {
model: 'gpt-test',
chatProvider: provider,
})
await harness.runtime.ingest('second turn', {
model: 'gpt-test',
chatProvider: provider,
})
expect(harness.telemetry.chatActivationStarted).toHaveLength(1)
expect(harness.telemetry.chatActivationSucceeded).toHaveLength(1)
expect(harness.telemetry.chatActivationFailed).toHaveLength(0)
expect(harness.telemetry.messageSendStarted).toHaveLength(2)
expect(harness.telemetry.messageRound).toHaveLength(2)
})
/**
* @example
* await expect(runtime.ingest('hello', { model, chatProvider })).rejects.toThrow('provider rejected')
@ -429,60 +358,19 @@ describe('createChatOrchestratorRuntime', () => {
})).rejects.toThrow('provider rejected')
expect(harness.telemetry.chatActivationStarted).toEqual([{
conversationId: 'session-1',
model: 'gpt-test',
provider: 'mock-provider',
roundId: 'user-id',
sessionId: 'session-1',
source: 'text',
turnIndex: 1,
}])
expect(harness.telemetry.chatActivationSucceeded).toEqual([])
expect(harness.telemetry.chatActivationFailed).toEqual([{
conversationId: 'session-1',
errorCode: 'llm_response_failed',
failureStage: 'llm_response',
model: 'gpt-test',
provider: 'mock-provider',
roundId: 'user-id',
source: 'text',
turnIndex: 1,
}])
expect(harness.telemetry.messageRoundFailed).toEqual([{
conversationId: 'session-1',
errorCode: 'llm_response_failed',
failureStage: 'llm_response',
model: 'gpt-test',
provider: 'mock-provider',
roundId: 'user-id',
source: 'text',
turnIndex: 1,
}])
})
it('emits a round failure for later turns without repeating activation failure', async () => {
const harness = createHarness()
await harness.runtime.ingest('first turn succeeds', {
model: 'gpt-test',
chatProvider: provider,
})
harness.stream.mockRejectedValueOnce(new Error('later turn rejected'))
await expect(harness.runtime.ingest('second turn fails', {
model: 'gpt-test',
chatProvider: provider,
})).rejects.toThrow('later turn rejected')
expect(harness.telemetry.chatActivationFailed).toEqual([])
expect(harness.telemetry.messageRoundFailed).toEqual([
expect.objectContaining({
conversationId: 'session-1',
errorCode: 'llm_response_failed',
failureStage: 'llm_response',
roundId: expect.any(String),
turnIndex: 2,
}),
])
})
/**

View file

@ -4,7 +4,7 @@ import type { CommonContentPart, Message, ToolMessage } from '@xsai/shared-chat'
import type { AgentContextPort } from '../contracts/context-port'
import type { AgentForegroundStreamPort } from '../contracts/stream-port'
import type { ChatAssistantMessage, ChatHistoryItem, ChatSlices, ChatStreamEventContext, ContextMessage, StreamingAssistantMessage } from '../types/chat'
import type { LlmUsage, StreamEvent, StreamOptions } from '../types/llm'
import type { StreamEvent, StreamOptions } from '../types/llm'
import { createQueue } from '@proj-airi/stream-kit'
@ -156,16 +156,6 @@ export interface ChatOrchestratorRuntimeState {
pendingQueuedSendCount: number
}
/** Correlation keys shared by every analytics milestone from one user-to-assistant round. */
interface ChatRoundCorrelation {
/** Application conversation that owns the round. */
conversationId: string
/** Stable round key; the runtime reuses the persisted user-message ID. */
roundId: string
/** One-based user turn position within the conversation. */
turnIndex: number
}
/**
* Dependency surface used by the platform-agnostic chat orchestrator runtime.
*/
@ -200,21 +190,22 @@ export interface ChatOrchestratorRuntimeDeps {
onSendSettled?: (event: { sessionId: string }) => void
/** Called when a send starts and the first assistant placeholder is created. */
onTrackFirstMessage?: () => void
/** Called for attempts made before the conversation has its first assistant response. */
onChatActivationStarted?: (event: ChatRoundCorrelation & {
/** Called when a user starts a chat activation attempt. */
onChatActivationStarted?: (event: {
sessionId: string
source: 'text' | 'voice'
model: string
provider: string
}) => void
/** Called when the conversation reaches its first successful assistant response. */
onChatActivationSucceeded?: (event: ChatRoundCorrelation & {
/** Called after one user-to-assistant message round completes successfully. */
onChatActivationSucceeded?: (event: {
source: 'text' | 'voice'
model: string
provider: string
durationMs: number
}) => void
/** Called when a pre-activation attempt fails before assistant completion. */
onChatActivationFailed?: (event: ChatRoundCorrelation & {
/** Called after a chat activation attempt fails before assistant completion. */
onChatActivationFailed?: (event: {
source: 'text' | 'voice'
model: string
provider: string
@ -222,52 +213,31 @@ export interface ChatOrchestratorRuntimeDeps {
errorCode: 'llm_response_failed'
}) => void
/** Called when a user message send begins. */
onMessageSendStarted?: (event: ChatRoundCorrelation & {
onMessageSendStarted?: (event: {
source: 'text' | 'voice'
model: string
}) => void
/** Called immediately before the provider LLM request starts. */
onLlmRequestStarted?: (event: ChatRoundCorrelation & {
onLlmRequestStarted?: (event: {
model: string
provider: string
hasVoice: boolean
}) => void
/** Called when the first text token arrives from the provider stream. */
onLlmFirstToken?: (event: ChatRoundCorrelation & {
onLlmFirstToken?: (event: {
model: string
ttfbMs: number
}) => void
/** Called after the assistant stream is parsed and rendered into runtime state. */
onAssistantResponseRendered?: (event: ChatRoundCorrelation & {
onAssistantResponseRendered?: (event: {
model: string
latencyMs: number
}) => void
/** Called once per completed provider generation with content-free usage metadata. */
onLlmGeneration?: (event: ChatRoundCorrelation & {
model: string
provider: string
inputTokens?: number
outputTokens?: number
totalTokens?: number
usageSource: LlmUsage['source']
}) => void
/** Called after one user-to-assistant message round completes successfully. */
onMessageRound?: (event: ChatRoundCorrelation & {
onMessageRound?: (event: {
durationMs: number
hasVoice: boolean
model: string
inputTokens?: number
outputTokens?: number
totalTokens?: number
usageSource: LlmUsage['source']
}) => void
/** Called whenever a user-to-assistant round fails before completion. */
onMessageRoundFailed?: (event: ChatRoundCorrelation & {
source: 'text' | 'voice'
model: string
provider: string
failureStage: 'llm_response'
errorCode: 'llm_response_failed'
}) => void
/** Called for context/prompt lifecycle observability. */
onLifecycle?: (record: ChatOrchestratorLifecycleRecord) => void
@ -281,7 +251,6 @@ export interface ChatOrchestratorRuntimeDeps {
source: 'text' | 'voice'
model: string
provider: string
roundId: string
turnIndex: number
}) => void
/** Called after the assistant message has been finalized into session history. */
@ -417,14 +386,6 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
deps.session.ensureSession(sessionId)
const existingSessionMessages = deps.session.getSessionMessages(sessionId)
const turnIndex = existingSessionMessages.filter(message => message.role === 'user').length + 1
// Activation measures whether a conversation reaches its first assistant
// response. Later turns still emit message and latency telemetry, but they
// must not inflate the one-time activation milestones.
const isActivationAttempt = !existingSessionMessages.some(message => message.role === 'assistant')
// Datetime is no longer injected through the side-channel context store.
// It is applied at message-assembly time (see below) as a system-prompt
// date anchor + per-message [HH:MM] prefixes, which is more KV-cache
@ -468,25 +429,14 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
patchForegroundStream(sessionId, buildingMessage)
const sendSource = options.input ? 'voice' : 'text'
const activeProvider = deps.getActiveProvider?.() ?? ''
// The user message is the durable start of a round, so its ID also serves
// as the correlation key for every telemetry milestone emitted by it.
const roundId = createId()
const correlation: ChatRoundCorrelation = {
conversationId: sessionId,
roundId,
turnIndex,
}
deps.onTrackFirstMessage?.()
if (isActivationAttempt) {
deps.onChatActivationStarted?.({
...correlation,
source: sendSource,
model: options.model,
provider: activeProvider,
})
}
deps.onChatActivationStarted?.({
sessionId,
source: sendSource,
model: options.model,
provider: activeProvider,
})
deps.onMessageSendStarted?.({
...correlation,
source: sendSource,
model: options.model,
})
@ -523,13 +473,15 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
if (shouldAbort())
return
const userMessageId = createId()
const userMessage = {
role: 'user' as const,
content: finalContent,
createdAt: sendingCreatedAt,
id: roundId,
id: userMessageId,
}
deps.session.appendSessionMessage(sessionId, userMessage)
const userTurnIndex = deps.session.getSessionMessages(sessionId).filter(message => message.role === 'user').length
// Cloud sync v1: only the raw text part round-trips; image attachments
// and other non-text parts stay local.
@ -540,8 +492,7 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
source: sendSource,
model: options.model,
provider: activeProvider,
roundId,
turnIndex,
turnIndex: userTurnIndex,
})
const sessionMessagesForSend = deps.session.getSessionMessages(sessionId)
@ -692,9 +643,7 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
const llmRequestStartedAt = monotonicNow()
let llmFirstTokenEmitted = false
let generationUsage: LlmUsage = { source: 'unavailable' }
deps.onLlmRequestStarted?.({
...correlation,
model: options.model,
provider: deps.getActiveProvider() || 'unknown',
hasVoice: !!options.input,
@ -702,25 +651,9 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
await deps.llm.stream(options.model, options.chatProvider, newMessages as Message[], {
headers,
requestCorrelation: {
conversationId: correlation.conversationId,
roundId: correlation.roundId,
},
tools: options.tools,
waitForTools: true,
captureToolErrors: true,
onUsage: (usage) => {
generationUsage = usage
deps.onLlmGeneration?.({
...correlation,
model: options.model,
provider: activeProvider,
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
totalTokens: usage.totalTokens,
usageSource: usage.source,
})
},
onStreamEvent: async (event: StreamEvent) => {
switch (event.type) {
case 'tool-call':
@ -751,7 +684,6 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
if (!llmFirstTokenEmitted) {
llmFirstTokenEmitted = true
deps.onLlmFirstToken?.({
...correlation,
model: options.model,
ttfbMs: Math.round(monotonicNow() - llmRequestStartedAt),
})
@ -786,7 +718,6 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
await parser.end()
deps.onAssistantResponseRendered?.({
...correlation,
model: options.model,
latencyMs: Math.round(monotonicNow() - llmRequestStartedAt),
})
@ -820,45 +751,26 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
resetForegroundStream(sessionId)
const durationMs = Math.round(monotonicNow() - roundStartedAt)
deps.onMessageRound?.({
...correlation,
durationMs,
hasVoice: !!options.input,
model: options.model,
inputTokens: generationUsage.inputTokens,
outputTokens: generationUsage.outputTokens,
totalTokens: generationUsage.totalTokens,
usageSource: generationUsage.source,
})
if (isActivationAttempt) {
deps.onChatActivationSucceeded?.({
...correlation,
durationMs,
source: sendSource,
model: options.model,
provider: activeProvider,
})
}
deps.onChatActivationSucceeded?.({
durationMs,
source: sendSource,
model: options.model,
provider: activeProvider,
})
}
catch (error) {
console.error('Error sending message:', error)
deps.onMessageRoundFailed?.({
...correlation,
deps.onChatActivationFailed?.({
source: sendSource,
model: options.model,
provider: activeProvider,
failureStage: 'llm_response',
errorCode: 'llm_response_failed',
})
if (isActivationAttempt) {
deps.onChatActivationFailed?.({
...correlation,
source: sendSource,
model: options.model,
provider: activeProvider,
failureStage: 'llm_response',
errorCode: 'llm_response_failed',
})
}
throw error
}
finally {

View file

@ -27,119 +27,16 @@ const provider = {
}),
} as unknown as ChatProvider
function createMockStreamResult(
steps: Promise<unknown[]> = Promise.resolve([]),
totalUsage: Promise<{ prompt_tokens: number, completion_tokens: number, total_tokens: number } | undefined> = Promise.resolve(undefined),
) {
function createMockStreamResult(steps: Promise<unknown[]> = Promise.resolve([])) {
return {
steps,
messages: Promise.resolve([]),
usage: Promise.resolve(undefined),
totalUsage,
totalUsage: Promise.resolve(undefined),
}
}
describe('streamFrom tool error capture', () => {
it('requests final streaming usage and emits the reported token totals once', async () => {
const onUsage = vi.fn()
streamTextMock.mockReturnValueOnce(createMockStreamResult(
Promise.resolve([]),
Promise.resolve({ prompt_tokens: 12, completion_tokens: 8, total_tokens: 20 }),
))
await streamFrom({
model: 'model-a',
chatProvider: provider,
messages: [{ role: 'user', content: 'hello' }] as Message[],
options: { onUsage },
})
expect(streamTextMock).toHaveBeenCalledWith(expect.objectContaining({
streamOptions: { includeUsage: true },
}))
expect(onUsage).toHaveBeenCalledTimes(1)
expect(onUsage).toHaveBeenCalledWith({
inputTokens: 12,
outputTokens: 8,
totalTokens: 20,
source: 'reported',
})
})
it('marks usage unavailable when the provider omits the final usage chunk', async () => {
const onUsage = vi.fn()
streamTextMock.mockReturnValueOnce(createMockStreamResult())
await streamFrom({
model: 'model-a',
chatProvider: provider,
messages: [{ role: 'user', content: 'hello' }] as Message[],
options: { onUsage },
})
expect(onUsage).toHaveBeenCalledWith({ source: 'unavailable' })
})
it('marks usage unavailable when the final usage object has no token fields', async () => {
const onUsage = vi.fn()
streamTextMock.mockReturnValueOnce(createMockStreamResult(
Promise.resolve([]),
Promise.resolve({} as { prompt_tokens: number, completion_tokens: number, total_tokens: number }),
))
await streamFrom({
model: 'model-a',
chatProvider: provider,
messages: [{ role: 'user', content: 'hello' }] as Message[],
options: { onUsage },
})
expect(onUsage).toHaveBeenCalledWith({ source: 'unavailable' })
})
it('consumes totalUsage rejection when the stream fails before usage can be awaited', async () => {
const streamError = new Error('provider stream failed')
const totalUsageError = new Error('provider usage failed')
const unhandledRejections: unknown[] = []
const onUnhandledRejection = (reason: unknown) => {
unhandledRejections.push(reason)
}
process.on('unhandledRejection', onUnhandledRejection)
streamTextMock.mockReturnValueOnce(createMockStreamResult(
Promise.reject(streamError),
Promise.reject(totalUsageError),
))
try {
await expect(streamFrom({
model: 'model-a',
chatProvider: provider,
messages: [{ role: 'user', content: 'hello' }] as Message[],
})).rejects.toThrow('provider stream failed')
await new Promise(resolve => setImmediate(resolve))
expect(unhandledRejections).toEqual([])
}
finally {
process.off('unhandledRejection', onUnhandledRejection)
}
})
it('does not fail a completed generation when the usage observer throws', async () => {
streamTextMock.mockReturnValueOnce(createMockStreamResult())
await expect(streamFrom({
model: 'model-a',
chatProvider: provider,
messages: [{ role: 'user', content: 'hello' }] as Message[],
options: {
onUsage: () => {
throw new Error('analytics unavailable')
},
},
})).resolves.toBeUndefined()
})
/**
* @example
* await streamFrom({ model, chatProvider, messages, options: { captureToolErrors: true } })

View file

@ -1,5 +1,5 @@
import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { Message, Tool, Usage } from '@xsai/shared-chat'
import type { Message, Tool } from '@xsai/shared-chat'
import type { StreamFromOptions, StreamOptions } from '../types/llm'
@ -113,19 +113,6 @@ function createCapturedToolErrorResult(toolName: string, error: unknown): string
return `Tool call error for "${toolName}": ${errorMessageFromValue(error)}`
}
function normalizeUsage(usage: Usage | undefined) {
if (!usage || (usage.prompt_tokens == null && usage.completion_tokens == null && usage.total_tokens == null)) {
return { source: 'unavailable' as const }
}
return {
inputTokens: usage.prompt_tokens,
outputTokens: usage.completion_tokens,
totalTokens: usage.total_tokens,
source: 'reported' as const,
}
}
function withCapturedToolErrors(
tools: Tool[],
capturedToolErrorByCallId: Map<string, string>,
@ -200,7 +187,6 @@ export async function streamFrom({
return new Promise<void>((resolve, reject) => {
let settled = false
let stepsSettled = false
const resolveOnce = () => {
if (settled)
return
@ -208,7 +194,7 @@ export async function streamFrom({
resolve()
}
const rejectOnce = (error: unknown) => {
if (settled || stepsSettled)
if (settled)
return
settled = true
reject(error)
@ -218,7 +204,13 @@ export async function streamFrom({
try {
const streamEvent = resolveCapturedToolErrorEvent(event, capturedToolErrorByCallId)
await options?.onStreamEvent?.(streamEvent as any)
if (event && (event as any).type === 'error') {
if (event && (event as any).type === 'finish') {
const finishReason = (event as any).finishReason
const waitingForToolRound = finishReason === 'tool_calls' || finishReason === 'tool-calls'
if (!waitingForToolRound || !options?.waitForTools)
resolveOnce()
}
else if (event && (event as any).type === 'error') {
rejectOnce((event as any).error ?? new Error('Stream error'))
}
}
@ -233,7 +225,6 @@ export async function streamFrom({
abortSignal: options?.abortSignal,
messages: sanitized,
headers: options?.headers,
streamOptions: { includeUsage: true },
stopWhen: stepCountAtLeast(10),
// NOTICE:
// Do not pass xsAI's `captureToolErrors` option here. In the installed
@ -258,42 +249,12 @@ export async function streamFrom({
// from starting.
// Keep `steps.then(resolveOnce)` so evaluation runners observe the real end
// of the stream lifecycle instead of an intermediate tool boundary.
void streamResult.steps.then(async () => {
// Ignore any late provider error event emitted after xsAI has already
// resolved the authoritative full-step lifecycle.
stepsSettled = true
let usage: Usage | undefined
try {
usage = await streamResult.totalUsage
}
catch (error) {
console.error('Stream totalUsage error:', error)
}
try {
await options?.onUsage?.(normalizeUsage(usage))
}
catch (error) {
// Usage observers are telemetry-only and must not turn a completed
// provider response into a failed user message.
console.error('Stream usage callback error:', error)
}
resolveOnce()
}).catch((error) => {
// A failure after `steps` resolved belongs to optional usage
// observation and cannot invalidate the completed response.
if (stepsSettled) {
console.error('Stream usage observation error:', error)
resolveOnce()
return
}
void streamResult.steps.then(resolveOnce).catch((error) => {
rejectOnce(error)
console.error('Stream steps error:', error)
})
void streamResult.messages.catch(error => console.error('Stream messages error:', error))
void streamResult.usage.catch(error => console.error('Stream usage error:', error))
// `steps` and `totalUsage` reject independently when xsAI fails a
// stream. The success path awaits `totalUsage`, but if `steps` rejects
// first that await never runs, so keep this unconditional rejection sink.
void streamResult.totalUsage.catch(error => console.error('Stream totalUsage error:', error))
}
catch (error) {

View file

@ -1,17 +1,6 @@
import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { CommonContentPart, CompletionToolCall, CompletionToolResult, Message, Tool } from '@xsai/shared-chat'
/** Describes whether generation usage came from the provider or a local fallback. */
export type LlmUsageSource = 'reported' | 'estimated' | 'unavailable'
/** Provider-safe token usage emitted after one complete streamed generation. */
export interface LlmUsage {
inputTokens?: number
outputTokens?: number
totalTokens?: number
source: LlmUsageSource
}
export type StreamEvent
= | { type: 'text-delta', text: string }
| { type: 'reasoning-delta', text: string }
@ -25,13 +14,6 @@ export interface StreamOptions {
abortSignal?: AbortSignal
headers?: Record<string, string>
onStreamEvent?: (event: StreamEvent) => void | Promise<void>
/** Called once after the full stream, including tool rounds, has settled. */
onUsage?: (usage: LlmUsage) => void | Promise<void>
/** Internal correlation kept out of the provider request body. */
requestCorrelation?: {
conversationId: string
roundId: string
}
toolsCompatibility?: Map<string, boolean>
supportsTools?: boolean
waitForTools?: boolean

View file

@ -1,7 +1,7 @@
{
"name": "@proj-airi/core-character",
"type": "module",
"version": "0.11.1",
"version": "0.11.0",
"private": true,
"description": "Core character pipeline orchestration for AIRI (segmentation, emotion, delay, optional TTS)",
"author": {

View file

@ -1,7 +1,7 @@
{
"name": "@proj-airi/electron-eventa",
"type": "module",
"version": "0.11.1",
"version": "0.11.0",
"private": true,
"description": "Shared Eventa contracts for Electron IPC",
"author": {

View file

@ -1,7 +1,7 @@
{
"name": "@proj-airi/electron-screen-capture",
"type": "module",
"version": "0.11.1",
"version": "0.11.0",
"private": true,
"description": "Screen capture",
"author": {

View file

@ -1,7 +1,7 @@
{
"name": "@proj-airi/electron-vueuse",
"type": "module",
"version": "0.11.1",
"version": "0.11.0",
"private": true,
"description": "VueUse-like composables and helpers for Electron apps",
"author": {

View file

@ -1,7 +1,7 @@
{
"name": "@proj-airi/font-chillroundm",
"type": "module",
"version": "0.11.1",
"version": "0.11.0",
"description": "Essential CSS for font, ChillRoundM (a.k.a. 寒蝉半圆体), used by Project AIRI",
"author": {
"name": "Warren2060",

Some files were not shown because too many files have changed in this diff Show more