mirror of
https://github.com/moeru-ai/airi.git
synced 2026-07-17 03:38:30 +00:00
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
3
.gitignore
vendored
|
|
@ -26,9 +26,6 @@ lerna-debug.log*
|
|||
*.local
|
||||
# e.g. Claude Code settings.local.json
|
||||
*.local.*
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Audio binaries
|
||||
*.pcm
|
||||
|
|
|
|||
|
|
@ -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 转发注册、支付、订阅等白名单业务事实到 PostHog;LLM / TTS per-request 事件不转发,PostHog 失败也不能影响请求主链路。
|
||||
- 前端 `posthog-js` 通过 `packages/stage-ui/src/stores/analytics/posthog.ts` 初始化,三个 app(web / 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` + Grafana;PostHog 走 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 connector;server 请求路径只写 Postgres/Grafana。
|
||||
|
||||
### 阶段 1(P0 — 付费漏斗 + 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 store,auth 端在 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`(最后一步同上)
|
||||
|
||||
### 阶段 2(P1 — retention / feature adoption / churn)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
- Table:Top `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`、自定义
|
|||
|
||||
- Grafana:5xx、LLM latency、provider failure、TTS blocked
|
||||
- PostHog:rage-click trend、failed frontend events
|
||||
- Table:Top error_code by app surface / provider
|
||||
- Table:Top error_code by surface / provider
|
||||
- Community tags:Discord / 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`。
|
||||
|
|
|
|||
|
|
@ -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.4;posthog-js 1.306.1;Node 26.3.0
|
||||
|
||||
## 用户路径
|
||||
|
||||
- **场景 1**:用户完成 Stripe 支付 → webhook 写 `product_events` → 服务端把 `payment_completed` 转发到 PostHog(distinctId = 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`)测试事件,分析时按事件名过滤。
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ function createService() {
|
|||
function createProductEventService(): ProductEventService {
|
||||
return {
|
||||
track: vi.fn(async () => undefined),
|
||||
trackGeneration: vi.fn(async () => undefined),
|
||||
countDistinctUsersByFeature: vi.fn(async () => []),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
AIRI_VERSION_NAME=0.11.1
|
||||
AIRI_VERSION_CODE=14
|
||||
AIRI_VERSION_NAME=0.11.0
|
||||
AIRI_VERSION_CODE=13
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:^",
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>', {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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']">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19640,7 +19640,7 @@
|
|||
}
|
||||
},
|
||||
"isOneTime": false,
|
||||
"monthlyDollars": -1,
|
||||
"monthlyDollars": 8,
|
||||
"privacyLevel": "PUBLIC",
|
||||
"tierName": "Patreon",
|
||||
"createdAt": "2026-04-03T08:34:28.264+00:00",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@proj-airi/docs",
|
||||
"type": "module",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "vitepress build",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
6
flake.lock
generated
|
|
@ -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": {
|
||||
|
|
|
|||
21
flake.nix
21
flake.nix
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue