Compare commits

...

3 commits

Author SHA1 Message Date
RainbowBird
0642ede8d1
feat(analytics): expand PostHog coverage and forward server business facts (#2038)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Build Test (stage-tamagotchi) (push) Has been cancelled
CI / Build Test (stage-tamagotchi-godot) (push) Has been cancelled
CI / Build Test (stage-web) (push) Has been cancelled
CI / Build Test (ui-loading-screens) (push) Has been cancelled
CI / Build Test (ui-transitions) (push) Has been cancelled
CI / Unit Test (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Check Provenance (push) Has been cancelled
Cloudflare Pages (Auth UI) / Deploy - ui-server-auth (push) Has been cancelled
Cloudflare Workers / Deploy - stage-web (push) Has been cancelled
Update Nix pnpmDeps Hash / update (push) Has been cancelled
## What

Closes the three structural gaps in AIRI's product analytics: the signup
surface had zero instrumentation, the payment funnel had no terminator
in PostHog, and SPA route changes emitted no pageviews. Also adds
semantic events for character cards, desktop-only features, and
data-maintenance actions.

## User paths

- User signs up / logs in / verifies email / resets password / links
OAuth / deletes account → each step now emits a PostHog event from
`apps/ui-server-auth` (previously fully uninstrumented), with
`identify()` wired on session load so anonymous funnel events merge into
the user person.
- User pays via Stripe → webhook writes `product_events` as before, and
the product-events service now forwards `payment_completed` (plus signup
and subscription lifecycle facts) to PostHog via posthog-node
`captureImmediate`, keyed by the Better Auth user id → the
`checkout_started → payment_completed` funnel closes end-to-end.
Per-request LLM/TTS volume is explicitly not forwarded.
- User navigates between routes in any surface (web / desktop / pocket /
docs) → `$pageview` + `$pageleave` fire per route change via the
posthog-js `defaults: '2025-05-24'` preset in the shared
`posthog.config.ts`.
- User creates / imports / duplicates / edits a ccv3 card, switches
stage background, runs destructive data actions (export / import / clear
chats, reset providers, wipe app data), or uses desktop differentiators
(Spotlight send, widget windows, in-app updater, MCP server management,
pairing QR) → dedicated low-cardinality events.

## Notable decisions

- Server forwarding defaults on: `POSTHOG_PROJECT_KEY` defaults to the
shared browser-safe phc_* project key; set it to an empty string to
disable. Postgres `product_events` remains the source of truth.
- Cross-surface events (`oauth_callback_failed`, account lifecycle)
share one stage vocabulary exported from stage-ui so the two emitters
cannot drift silently.
- Events captured right before full-page navigation use `sendBeacon` so
they survive the redirect (checkout, OAuth consent handoff, login
redirect).
- Removed dead wrappers (`trackSignup`, `trackFirstModelSelected`,
`trackModelChanged`) that duplicated live event streams under second
names.

## How tested

- `pnpm -F @proj-airi/server exec vitest run
src/services/domain/product-events.test.ts` — 6 passed, covering the
forwarding allowlist, the `user_signed_up → signup_completed` mapping,
non-forwarded per-request actions, and a throwing sink not failing the
webhook path nor losing the DB row.
- stage-ui suites (`use-analytics`, `use-linked-accounts`, exports
contract) — 22 passed, including new account/card/data/desktop event
assertions.
- Real transport smoke: posthog-node `captureImmediate` against
`us.i.posthog.com` with the production key resolved in 1380ms (one
`server_forwarding_smoke_test` event left in the project; filter by
event name).
- Browser-tested pageviews: `VITE_ENABLE_POSTHOG=true` dev build, two
`history.pushState` route changes each produced a `$pageview` with
`$pathname`, `navigation_type: pushState`, previous-page dwell time, and
the `surface` super property; batched POST to `us.i.posthog.com/e/`
returned 200. Note: posthog-js drops events from automated browsers
(`navigator.webdriver`) by default — the verification session bypassed
the bot filter locally; production config is untouched.
- Typecheck and lint pass for server, stage-ui, stage-pages, stage-web,
stage-tamagotchi, ui-server-auth.

Full verification record:
`apps/server/docs/ai-context/verifications/posthog-forwarding-and-pageview.md`

## Follow-ups (not in this PR)

- Bot channel usage stats (Discord / Telegram) once they route through
server-runtime counters.
- Main-process desktop events (tray menu, global shortcut fire) need
renderer relay plumbing.
- Confirm `payment_completed` arrives in PostHog after the first real
Stripe payment post-deploy.

https://claude.ai/code/session_01Q1yGavkQ1P41YhTWE4XKex
2026-07-08 15:33:39 +08:00
Doji
5aa44aedd3
feat(stage-ui): add character card import and export (#1998)
Some checks are pending
CI / Lint (push) Waiting to run
CI / Build Test (stage-tamagotchi) (push) Waiting to run
CI / Build Test (stage-tamagotchi-godot) (push) Waiting to run
CI / Build Test (stage-web) (push) Waiting to run
CI / Build Test (ui-loading-screens) (push) Waiting to run
CI / Build Test (ui-transitions) (push) Waiting to run
CI / Unit Test (push) Waiting to run
CI / Type Check (push) Waiting to run
CI / Check Provenance (push) Waiting to run
Cloudflare Pages (Auth UI) / Deploy - ui-server-auth (push) Waiting to run
Cloudflare Workers / Deploy - stage-web (push) Waiting to run
Update Nix pnpmDeps Hash / update (push) Waiting to run
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-08 14:14:43 +08:00
Neko
de0ff177ad
chore: update sponsors svg (#2036)
This PR updates generated SponsorKit assets from the scheduled sponsors
workflow.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-08 04:27:39 +00:00
60 changed files with 1910 additions and 197 deletions

View file

@ -130,7 +130,8 @@
| 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` / `first_message_sent` | 前端 | `packages/stage-ui/src/composables/use-analytics.ts` | PostHog |
| 老事件 | `provider_card_clicked` | 前端 | `packages/stage-ui/src/composables/use-analytics.ts` | PostHog |
| 已退役 | `first_message_sent` / `user_signed_up`(前端版) | 无生产者 | wrapper 已删除;激活口径用 `chat_activation_succeeded`,注册事实用服务端转发的 `signup_completed` | PostHog 历史数据仍在 |
待埋点API 已在 `use-analytics.ts` 暴露但调用点未接入):
@ -150,22 +151,21 @@
## PostHog 接入路线图
落地分两步,**不要一次性埋全部事件**,否则 schema 漂移会很快出现。PostHog 采集只发生在前端或外部 source connectorserver 请求路径只写 Postgres/Grafana。
落地分两步,**不要一次性埋全部事件**,否则 schema 漂移会很快出现。PostHog 采集以前端为主;服务端只经 product-events 白名单转发业务事实注册、支付、订阅per-request 路径仍只写 Postgres/Grafana。
### 阶段 1P0 — 付费漏斗 + activation
`apps/stage-web`
所有 surface 共用根目录 `posthog.config.ts`(单一 project key`surface` super property 区分端)。初始化实况
```ts
import posthog from 'posthog-js'
import { DEFAULT_POSTHOG_CONFIG, POSTHOG_PROJECT_KEY } from '../posthog.config'
posthog.init(import.meta.env.VITE_POSTHOG_KEY, {
api_host: 'https://us.i.posthog.com',
capture_pageview: false, // 手动 capture 控制语义
})
// 登录后
// DEFAULT_POSTHOG_CONFIG 内含 defaults: '2025-05-24'
// SPA 路由切换自动发 $pageview / $pageleave页面浏览不再手动埋。
posthog.init(POSTHOG_PROJECT_KEY, { ...DEFAULT_POSTHOG_CONFIG })
// 登录后stage 端在 analytics storeauth 端在 profile.vue
posthog.identify(user.id)
// 在 pricing.vue
// 在 flux.vue
posthog.capture('pricing_page_viewed', { plan_period, source })
```
@ -184,13 +184,13 @@ posthog.init(import.meta.env.VITE_POSTHOG_KEY, {
埋点事件清单P0
- 前端:`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 或离线导入展示
- 前端:`pricing_page_viewed``plan_selected``checkout_started``signup_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 事件不转发
PostHog UI 配两个 funnel
- **付费漏斗** (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`(最后一步同上)
- **付费漏斗** (7d 窗口)`pricing_page_viewed → plan_selected → checkout_started → payment_completed`(最后一步来自服务端转发
- **激活漏斗** (14d 窗口)`signup_completed → onboarding_started → chat_activation_succeeded → payment_completed`
### 阶段 2P1 — retention / feature adoption / churn

View file

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

View file

@ -61,6 +61,7 @@
"nanoid": "catalog:",
"ofetch": "catalog:",
"pg": "catalog:",
"posthog-node": "catalog:",
"resend": "catalog:",
"stripe": "catalog:",
"unspeech": "catalog:xsai",

View file

@ -75,6 +75,7 @@ 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'
@ -593,9 +594,27 @@ 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 },
build: ({ dependsOn }) => createProductEventService(dependsOn.db, dependsOn.otel?.product),
dependsOn: { db, otel, posthogSink },
build: ({ dependsOn }) => createProductEventService(dependsOn.db, dependsOn.otel?.product, dependsOn.posthogSink),
})
const characterService = injeca.provide('services:characters', {

View file

@ -175,6 +175,14 @@ 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://us.i.posthog.com'),
// OpenTelemetry
OTEL_SERVICE_NAMESPACE: optional(string(), 'airi'),
OTEL_SERVICE_NAME: optional(string(), 'server'),

View file

@ -0,0 +1,59 @@
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 {
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.
*
* Uses `captureImmediate` (one HTTP roundtrip per event, no background
* queue) on purpose: the forwarded events are low-frequency business facts
* (signup, payment, subscription lifecycle) fired from webhook/auth-hook
* paths where a queued batch could be lost on process exit.
*
* 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 {
async capture(input: PosthogCaptureInput): Promise<void> {
try {
await client.captureImmediate({
distinctId: input.distinctId,
event: input.event,
properties: input.properties,
})
}
catch (err) {
logger.withError(err).withFields({ event: input.event }).warn('Failed to forward product event to PostHog')
}
},
async shutdown(): Promise<void> {
await client.shutdown()
},
}
}

View file

@ -1,6 +1,7 @@
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'
@ -136,4 +137,137 @@ 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: {
surface: 'server',
feature: 'billing',
status: 'succeeded',
source: 'stripe.webhook',
amount_minor_unit: 990,
currency: 'usd',
},
})
expect(capture).toHaveBeenNthCalledWith(2, {
distinctId: 'user-2',
event: 'signup_completed',
properties: {
surface: 'server',
feature: 'auth',
status: 'succeeded',
},
})
const rows = await db.select().from(schema.productEvents)
expect(rows).toHaveLength(2)
})
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('does not forward to PostHog when the DB write fails', async () => {
// ROOT CAUSE:
//
// track() swallows DB insert errors to protect the caller, but the
// PostHog forwarding block ran unconditionally afterwards — a Postgres
// outage during a Stripe webhook would mint `payment_completed` in
// PostHog with no `product_events` row backing it, breaking the
// "Postgres is the fact of record" invariant and later reconciliation.
// Found by PR #2038 review.
//
// Fixed by gating forwarding on a `persisted` flag set only after the
// insert resolves.
const capture = vi.fn(async () => {})
const sink = { capture, shutdown: vi.fn(async () => {}) }
const service = createProductEventService(db, null, sink)
// Simulate a DB outage by renaming the table out from under the insert.
await db.execute(sql`ALTER TABLE product_events RENAME TO product_events_outage`)
try {
await expect(service.track({
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
})).resolves.toBeUndefined()
}
finally {
await db.execute(sql`ALTER TABLE product_events_outage RENAME TO product_events`)
}
expect(capture).not.toHaveBeenCalled()
const rows = await db.select().from(schema.productEvents)
expect(rows).toHaveLength(0)
})
it('persists the product event even when a misbehaving sink throws', async () => {
// ROOT CAUSE:
//
// The PosthogSink contract says implementations swallow transport
// errors, but the forwarding call sits on the Stripe webhook path —
// if a sink ever throws, an unguarded `await` would fail the webhook
// after the fact was already persisted, causing Stripe to retry and
// (before the idempotency guard) double-process the payment.
//
// track() therefore wraps forwarding in its own try/catch: the DB row
// must survive and track() must resolve regardless of sink behavior.
const capture = vi.fn(async () => {
throw new Error('posthog exploded')
})
const sink = { capture, shutdown: vi.fn(async () => {}) }
const service = createProductEventService(db, null, sink)
await expect(service.track({
userId: 'user-1',
feature: 'billing',
action: 'payment_completed',
status: 'succeeded',
})).resolves.toBeUndefined()
const rows = await db.select().from(schema.productEvents)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ action: 'payment_completed', status: 'succeeded' })
})
})

View file

@ -1,6 +1,7 @@
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'
@ -75,6 +76,24 @@ export interface ProductEventAggregateRow {
distinctUsers: number
}
/**
* 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 that is the canonical
* activation-funnel step name the browser surfaces emit; the server copy
* covers OAuth signups the auth UI cannot classify client-side.
*/
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.
*/
@ -113,9 +132,14 @@ function metricLabels(input: ProductEventInput): Record<string, string> {
* Returns:
* - Best-effort event writer plus a DB aggregation helper for analytics jobs.
*/
export function createProductEventService(db: Database, metrics?: ProductMetrics | null) {
export function createProductEventService(db: Database, metrics?: ProductMetrics | null, posthog?: PosthogSink | null) {
return {
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,
@ -129,6 +153,7 @@ export function createProductEventService(db: Database, metrics?: ProductMetrics
metadata: input.metadata,
createdAt: input.createdAt,
})
persisted = true
metrics?.events.add(1, metricLabels(input))
}
@ -140,6 +165,32 @@ 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 {
await posthog.capture({
distinctId: input.userId,
event: forwardedEvent,
properties: {
surface: 'server',
feature: input.feature,
status: input.status,
...(input.source && { source: input.source }),
...(input.reason && { reason: input.reason }),
...input.metadata,
},
})
}
catch (err) {
// The sink contract already swallows transport errors; this guard
// is the last line so a misbehaving sink can never fail the
// webhook/auth flow that produced the business fact.
logger.withError(err).withFields({ action: input.action }).warn('PostHog forwarding threw; product event already persisted')
}
}
},
async countDistinctUsersByFeature(input: ProductEventAggregateInput): Promise<ProductEventAggregateRow[]> {

View file

@ -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 { useBreakpoints } from '@proj-airi/stage-ui/composables'
import { useAnalytics, 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 } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { electronGetUpdaterPreferences, electronSetUpdaterPreferences } from '../../shared/eventa'
@ -32,6 +32,13 @@ 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
@ -119,6 +126,23 @@ 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
@ -181,9 +205,16 @@ 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 {
@ -325,7 +356,7 @@ onMounted(() => {
<div>
<DoubleCheckButton
variant="primary"
@confirm="quitAndInstall()"
@confirm="handleQuitAndInstall()"
>
{{ restartButtonLabel }}
<template #confirm>
@ -366,7 +397,7 @@ onMounted(() => {
: isError
? t('tamagotchi.stage.about.update.actions.retry-check')
: t('tamagotchi.stage.about.update.actions.check-for-updates')"
@click="checkForUpdates()"
@click="handleCheckForUpdates()"
/>
</div>
</div>

View file

@ -3,6 +3,7 @@ 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'
@ -13,7 +14,14 @@ 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)
@ -82,7 +90,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="slotProps.setVisible(!slotProps.visible)"
@click="handleToggleExpanded(!slotProps.visible, slotProps.setVisible)"
>
<div :class="['min-w-0 flex flex-col gap-1']">
<div :class="['text-sm font-medium text-neutral-900 dark:text-neutral-100']">

View file

@ -2,18 +2,21 @@
import type { DataSettingsStatusEmits } from '@proj-airi/stage-pages/pages/settings/data/status'
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 { DoubleCheckButton } from '@proj-airi/ui'
import { useI18n } from 'vue-i18n'
const emit = defineEmits<DataSettingsStatusEmits>()
const { t } = useI18n()
const { trackDataAction } = useAnalytics()
const { resetDesktopApplicationState } = useDataMaintenance()
const { emitStatus, handleActionError } = createDataSettingsStatusHelpers(emit)
async function resetDesktopState() {
try {
await resetDesktopApplicationState()
trackDataAction({ action: 'desktop_state_reset' })
emitStatus(t('settings.pages.data.status.desktop_reset'))
}
catch (error) {

View file

@ -14,6 +14,7 @@ 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'
@ -272,9 +273,12 @@ 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) {
@ -291,6 +295,12 @@ async function handleGodotStageToggle() {
await refreshGodotStageStatus()
}
finally {
trackFeatureUsed({
feature_name: enablingGodot ? 'godot_stage_enabled' : 'godot_stage_disabled',
business_domain: 'stage_rendering',
entry: 'settings',
success: godotStageError.value === undefined,
})
switchingGodotStage.value = false
}
}

View file

@ -8,6 +8,7 @@ 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,
@ -41,6 +42,7 @@ 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)
@ -213,14 +215,17 @@ 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)
@ -320,6 +325,7 @@ async function runConnectionTest() {
testResult.value = { ok: false, error: errorMessageFrom(e) ?? 'Unknown error', durationMs: 0 }
}
finally {
trackMcpConnectionTestRun({ success: testResult.value?.ok === true })
testRunning.value = false
}
}

View file

@ -3,6 +3,7 @@ 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'
@ -17,6 +18,7 @@ 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}`)
@ -64,6 +66,13 @@ async function saveShortcut(next: ShortcutAccelerator | null) {
return
}
accelerator.value = result.actualAccelerator ?? next ?? accelerator.value
// Value is intentionally coarse (customized / reset) the exact key
// combo is a fingerprinting-adjacent detail no dashboard needs.
trackSettingsChanged({
setting_name: 'spotlight_shortcut',
new_value: next ? 'customized' : 'reset_to_default',
source: 'settings',
})
}
catch {
toast.error(tt('errors.failed'))

View file

@ -1,6 +1,7 @@
<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'
@ -17,6 +18,7 @@ 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()
@ -39,6 +41,7 @@ async function handleSend() {
messageInput.value = ''
sending.value = true
trackSpotlightUsed()
try {
await hideSpotlightWindow()

View file

@ -2,6 +2,7 @@
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'
@ -100,6 +101,8 @@ async function requestSnapshot(id: string) {
}
}
const { trackWidgetOpened } = useAnalytics()
watch(widgetId, (id) => {
clearTtl()
widget.value = null
@ -107,6 +110,7 @@ watch(widgetId, (id) => {
loading.value = false
if (!id)
return
trackWidgetOpened({ widget_id: id })
requestSnapshot(id)
}, { immediate: true })

View file

@ -1,5 +1,6 @@
<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'
@ -9,6 +10,7 @@ import { useRouter } from 'vue-router'
const router = useRouter()
const { t } = useI18n()
const { trackOauthCallbackFailed } = useAnalytics()
const error = ref<string | null>(null)
onMounted(async () => {
@ -18,17 +20,20 @@ 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
}
@ -40,6 +45,7 @@ onMounted(async () => {
router.replace('/')
}
catch (err) {
trackOauthCallbackFailed({ stage: 'token_exchange_failed' })
error.value = errorMessageFrom(err) ?? t('server.auth.webCallback.message.tokenExchangeFailed')
}
})

View file

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

View file

@ -1,6 +1,7 @@
<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'
@ -9,6 +10,7 @@ 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)
@ -45,7 +47,9 @@ 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).catch(console.error)
characterStore.remove(id)
.then(() => trackCharacterDeleted({ character_id: id }))
.catch(console.error)
}
}

View file

@ -14,6 +14,7 @@ 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'
@ -23,6 +24,8 @@ import 'vue-sonner/style.css'
import './styles/main.css'
import 'uno.css'
initAuthAnalytics()
const pinia = createPinia()
// TODO: vite-plugin-vue-layouts is long deprecated, replace with another layout solution

View file

@ -0,0 +1,167 @@
/**
* PostHog product analytics for the auth-only SPA (`apps/ui-server-auth`).
*
* This surface is the top of the activation funnel: sign-up, sign-in, email
* verification, password recovery. Events captured here are the funnel
* entry that the in-app surfaces (`signup_completed → onboarding_started →
* first message`) join against, keyed by the Better Auth user id passed to
* {@link identifyAuthUser} the same id the server uses as `distinctId`
* for its own events, so the person profiles merge.
*
* 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 `surface` super
// property is how auth traffic is told apart in shared dashboards.
posthog.register({ 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,
)
}
/** Activation funnel step 1 — the account now exists (email flow). */
export function trackSignupCompleted(properties: { source: AuthMethod, requires_verification: boolean }): void {
capture('signup_completed', properties, { beforeNavigation: !properties.requires_verification })
}
/**
* OAuth flows leave the page before their outcome is knowable, so the
* client can only record the attempt; completion shows up as the
* identified session on the callback landing.
*/
export function trackLoginStarted(properties: { method: AuthMethod }): void {
capture('login_started', properties, { beforeNavigation: true })
}
/** Credential sign-in succeeded; OIDC continuation navigation follows. */
export function trackLoginSucceeded(properties: { method: AuthMethod }): void {
capture('login_succeeded', properties, { beforeNavigation: true })
}
/**
* Sign-in attempt failed. No error detail on purpose auth error messages
* can embed the email address, and the count per method is what the funnel
* needs.
*/
export function trackLoginFailed(properties: { method: AuthMethod }): void {
capture('login_failed', properties)
}
/** Verification link landing with `?verified=true`. */
export function trackEmailVerificationCompleted(): void {
capture('email_verification_completed', {})
}
/** Verification link landing with `?error=...`. */
export function trackEmailVerificationFailed(): void {
capture('email_verification_failed', {})
}
export function trackPasswordResetRequested(): void {
capture('password_reset_requested', {})
}
export function trackPasswordResetCompleted(): void {
capture('password_reset_completed', {})
}
export function trackPasswordChanged(): void {
capture('password_changed', {})
}
/**
* Link handed off to the provider's consent page. Completion is not
* client-observable (it lands back via a full-page OAuth redirect), so the
* funnel pairs this with the refreshed linked-accounts state server-side.
*/
export function trackOauthProviderLinkStarted(properties: { provider: string }): void {
capture('oauth_provider_link_started', properties, { beforeNavigation: true })
}
export function trackOauthProviderUnlinked(properties: { provider: string }): void {
capture('oauth_provider_unlinked', properties)
}
/**
* Deletion-confirmed landing page reached (`delete-account.vue`). The
* deletion request itself is raised from the stage apps' account settings.
*/
export function trackAccountDeletionCompleted(): void {
capture('account_deletion_completed', {})
}
export function trackSignedOut(): void {
capture('signed_out', {})
}
/**
* Electron OIDC relay handoff failed. `stage` distinguishes a malformed
* callback (`parse`) from an unreachable local app (`relay_unreachable`);
* the full cross-surface vocabulary lives in stage-ui's
* `OauthCallbackFailureStage` so the two emitters share one schema.
*/
export function trackOauthCallbackFailed(properties: { stage: Extract<OauthCallbackFailureStage, 'parse' | 'relay_unreachable'> }): void {
capture('oauth_callback_failed', properties)
}

View file

@ -1,8 +1,10 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted } 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
@ -28,6 +30,14 @@ const errorMessage = computed(() => {
const value = route.query.error
return typeof value === 'string' ? value : null
})
onMounted(() => {
// Strongest churn fact the client can observe: better-auth only
// redirects here after the deletion callback already ran server-side,
// so rendering the success state means the account is gone.
if (!errorMessage.value)
trackAccountDeletionCompleted()
})
</script>
<template>

View file

@ -4,6 +4,7 @@ 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'
@ -55,6 +56,7 @@ 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,
@ -98,6 +100,7 @@ async function runRelayFlow() {
}, 1200)
}
catch {
trackOauthCallbackFailed({ stage: 'relay_unreachable' })
setViewModel({
description: t('server.auth.electronCallback.message.loopbackUnreachable'),
detail: t('server.auth.electronCallback.message.tryOpenDirectly'),

View file

@ -4,6 +4,7 @@ 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'
@ -36,6 +37,7 @@ async function handleSubmit(event: Event) {
email: form.email.trim(),
redirectTo: resetRedirect,
})
trackPasswordResetRequested()
submitted.value = true
}
catch (error) {

View file

@ -9,6 +9,14 @@ 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 {
@ -98,6 +106,8 @@ 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(() => {
@ -138,6 +148,9 @@ 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')
@ -200,6 +213,7 @@ 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')
@ -237,6 +251,7 @@ 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)
@ -256,6 +271,7 @@ async function handleSignOut() {
try {
await signOut({ apiServerUrl })
trackSignedOut()
await router.replace('/sign-in')
}
catch (error) {

View file

@ -5,6 +5,7 @@ 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'
@ -52,6 +53,7 @@ async function handleSubmit(event: Event) {
newPassword: form.password,
token: token.value,
})
trackPasswordResetCompleted()
completed.value = true
}
catch (error) {

View file

@ -8,6 +8,12 @@ import { computed, reactive, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import {
trackLoginFailed,
trackLoginStarted,
trackLoginSucceeded,
trackSignupCompleted,
} from '../modules/analytics'
import { buildCurrentOriginAuthUiUrl } from '../modules/auth-ui-base'
import {
checkEmail,
@ -127,9 +133,11 @@ 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
}
@ -202,9 +210,11 @@ 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 {
@ -237,6 +247,7 @@ async function handleEmailSignUp(event: Event) {
})
if (result.requiresVerification) {
trackSignupCompleted({ source: 'email', requires_verification: true })
await router.push({
path: '/verify-email',
query: {
@ -249,6 +260,7 @@ async function handleEmailSignUp(event: Event) {
// Verification disabled at server config: session is live, fall through
// to the OIDC continuation just like sign-in.
trackSignupCompleted({ source: 'email', requires_verification: false })
window.location.href = effectiveCallbackURL.value
}
catch (error) {

View file

@ -5,6 +5,7 @@ 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'
@ -87,13 +88,16 @@ 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)
if (error.value) {
trackEmailVerificationFailed()
return
}
// Pending tab: cover the case where verification already happened before
// this tab subscribed (back-button navigation, page reload, etc.). One

View file

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

View file

@ -409,8 +409,14 @@ pages:
systemprompt: System Prompt
title: AIRI Card
try_different_search: Try a different search term
export: Export
exported: Card package exported
export_failed: Failed to export card package
imported: Card package imported
import_missing_file: Card package is missing required files
import_invalid_file: Card package file is invalid
upload: Upload
upload_desc: Click or drag file to upload
upload_desc: Click or drag an AIRI card package to import
memory:
description: Where memories got stored, and organized
title: Memory

View file

@ -390,8 +390,14 @@ pages:
systemprompt: Prompt del Sistema
title: Tarjeta AIRI
try_different_search: Prueba un término de búsqueda diferente
export: Exportar
exported: Paquete de tarjeta exportado
export_failed: No se pudo exportar el paquete de tarjeta
imported: Paquete de tarjeta importado
import_missing_file: Al paquete de tarjeta le faltan archivos necesarios
import_invalid_file: El archivo del paquete de tarjeta no es válido
upload: Subir
upload_desc: Haz clic o arrastra el archivo para subir
upload_desc: Haz clic o arrastra un paquete de tarjeta AIRI para importar
memory:
description: Donde se almacenan y organizan las memorias
title: Memoria

View file

@ -390,8 +390,14 @@ pages:
systemprompt: Prompt système
title: Carte AIRI
try_different_search: Essayez un autre terme de recherche
export: Exporter
exported: Paquet de carte exporté
export_failed: Impossible dexporter le paquet de carte
imported: Paquet de carte importé
import_missing_file: Le paquet de carte ne contient pas les fichiers requis
import_invalid_file: Le fichier du paquet de carte est invalide
upload: Téléverser
upload_desc: Cliquez ou glissez un fichier pour téléverser
upload_desc: Cliquez ou glissez un paquet de carte AIRI pour limporter
memory:
description: Endroit où les souvenirs sont stockés et organisés
title: Mémoire

View file

@ -390,8 +390,14 @@ pages:
systemprompt: システムプロンプト
title: AIRIカード
try_different_search: 別の検索ワードを試してください
export: エクスポート
exported: カードパッケージをエクスポートしました
export_failed: カードパッケージのエクスポートに失敗しました
imported: カードパッケージをインポートしました
import_missing_file: カードパッケージに必要なファイルがありません
import_invalid_file: カードパッケージファイルが正しくありません
upload: アップロード
upload_desc: ファイルをアップロードするにはクリックまたはドラッグ
upload_desc: AIRI カードパッケージをクリックまたはドラッグしてインポート
memory:
description: 記憶が保存され、整理される場所
title: 記憶

View file

@ -390,8 +390,14 @@ pages:
systemprompt: 시스템 프롬프트
title: AIRI 카드
try_different_search: 다른 검색어를 시도해보세요
export: 내보내기
exported: 카드 패키지를 내보냈습니다
export_failed: 카드 패키지를 내보내지 못했습니다
imported: 카드 패키지를 가져왔습니다
import_missing_file: 카드 패키지에 필요한 파일이 없습니다
import_invalid_file: 카드 패키지 파일이 올바르지 않습니다
upload: 업로드
upload_desc: 클릭 또는 파일을 드래그하여 업로드
upload_desc: AIRI 카드 패키지를 클릭하거나 드래그하여 가져오기
memory:
description: 기억이 저장되고 정리된 곳
title: 메모리

View file

@ -390,8 +390,14 @@ pages:
systemprompt: Системный промпт
title: Карта AIRI
try_different_search: Попробуйте другой поисковый запрос
export: Экспорт
exported: Пакет карточки экспортирован
export_failed: Не удалось экспортировать пакет карточки
imported: Пакет карточки импортирован
import_missing_file: В пакете карточки отсутствуют необходимые файлы
import_invalid_file: Файл пакета карточки недействителен
upload: Загрузить
upload_desc: Нажмите или перетащите файл для загрузки
upload_desc: Нажмите или перетащите пакет карточки AIRI для импорта
memory:
description: Хранилище и организация воспоминаний
title: Память

View file

@ -390,8 +390,14 @@ pages:
systemprompt: Lời nhắc hệ thống
title: Thẻ AIRI
try_different_search: Hãy thử từ khóa khác
export: Xuất
exported: Đã xuất gói thẻ
export_failed: Không thể xuất gói thẻ
imported: Đã nhập gói thẻ
import_missing_file: Gói thẻ thiếu tệp bắt buộc
import_invalid_file: Tệp gói thẻ không hợp lệ
upload: Tải lên
upload_desc: Bấm hoặc kéo thả tệp để tải lên
upload_desc: Bấm hoặc kéo thả gói thẻ AIRI để nhập
memory:
description: Nơi lưu trữ và tổ chức ký ức
title: Bộ nhớ

View file

@ -390,8 +390,14 @@ pages:
systemprompt: 系统提示词
title: AIRI 角色卡
try_different_search: 尝试使用其他关键词搜索
export: 导出
exported: 角色卡包已导出
export_failed: 角色卡包导出失败
imported: 角色卡包已导入
import_missing_file: 角色卡包缺少必要文件
import_invalid_file: 角色卡包文件错误
upload: 上传
upload_desc: 点击或拖拽文件到此处上传
upload_desc: 点击或拖拽 AIRI 角色卡包到此处导入
memory:
description: 存放记忆的地方,以及策略
title: 记忆体

View file

@ -390,8 +390,14 @@ pages:
systemprompt: 系統提示詞
title: AIRI 角色卡
try_different_search: 嘗試使用其他關鍵字搜尋
export: 匯出
exported: 角色卡包已匯出
export_failed: 角色卡包匯出失敗
imported: 角色卡包已匯入
import_missing_file: 角色卡包缺少必要檔案
import_invalid_file: 角色卡包檔案錯誤
upload: 上傳
upload_desc: 點擊或拖曳檔案到此處上傳
upload_desc: 點擊或拖曳 AIRI 角色卡包到此處匯入
memory:
description: 存放記憶的地方,以及策略
title: 記憶體

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { errorMessageFrom } from '@moeru/std'
import { defaultSignInProviders } from '@proj-airi/stage-ui/components/auth'
import { resolveLinkedAccountOAuthErrorMessageKey, useLinkedAccounts } from '@proj-airi/stage-ui/composables'
import { resolveLinkedAccountOAuthErrorMessageKey, useAnalytics, useLinkedAccounts } from '@proj-airi/stage-ui/composables'
import { authClient } from '@proj-airi/stage-ui/libs/auth'
import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
@ -22,6 +22,13 @@ const emit = defineEmits<{
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const {
trackAccountDeletionRequested,
trackOauthProviderLinkStarted,
trackOauthProviderUnlinked,
trackPasswordChanged,
trackPasswordResetRequested,
} = useAnalytics()
const authStore = useAuthStore()
const { isAuthenticated, user, credits } = storeToRefs(authStore)
@ -157,6 +164,8 @@ const {
unlinked: provider => t('settings.pages.account.connections.message.unlinked', { provider }),
linkStarted: provider => t('settings.pages.account.connections.message.linkStarted', { provider }),
},
onUnlinked: providerId => trackOauthProviderUnlinked({ provider: providerId }),
onLinkStarted: providerId => trackOauthProviderLinkStarted({ provider: providerId }),
})
watch(
@ -279,6 +288,7 @@ async function handleChangePassword(event: Event) {
passwordForm.next = ''
passwordForm.confirm = ''
passwordSuccess.value = t('settings.pages.account.security.message.changed')
trackPasswordChanged()
}
catch (error) {
passwordError.value = errorMessageFrom(error) ?? t('settings.pages.account.security.error.fallback')
@ -316,6 +326,7 @@ async function handleSendSetPasswordLink() {
if (error)
throw new Error(error.message ?? 'requestPasswordReset failed')
setPasswordSuccess.value = t('settings.pages.account.security.message.setLinkSent', { email })
trackPasswordResetRequested()
}
catch (error) {
setPasswordError.value = errorMessageFrom(error) ?? t('settings.pages.account.security.error.setLinkFailed')
@ -390,6 +401,7 @@ async function handleConfirmDelete(event: Event) {
deleteSent.value = true
deleteDialogOpen.value = false
trackAccountDeletionRequested()
}
catch (error) {
deleteError.value = errorMessageFrom(error) ?? t('settings.pages.account.danger.deleteAccount.error.fallback')

View file

@ -5,6 +5,7 @@ import type { AiriExtension } from '@proj-airi/stage-ui/stores/modules/airi-card
import kebabcase from '@stdlib/string-base-kebabcase'
import { isCustomProvidersDisabled } from '@proj-airi/stage-shared'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { DEFAULT_ARTISTRY_WIDGET_INSTRUCTION } from '@proj-airi/stage-ui/constants/prompts/artistry-instruction'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
@ -58,6 +59,7 @@ const emit = defineEmits<{
const modelValue = defineModel<boolean>()
const { t } = useI18n()
const { trackCardEdited } = useAnalytics()
const cardStore = useAiriCardStore()
const consciousnessStore = useConsciousnessStore()
const visionStore = useVisionStore()
@ -417,10 +419,11 @@ function saveCard(card: Card): boolean {
if (isEditMode.value && props.cardId) {
// Edit mode: update existing card
cardStore.updateCard(props.cardId, cardWithModules)
trackCardEdited({ card_id: props.cardId })
}
else {
// Create mode: add new card
cardStore.addCard(cardWithModules)
cardStore.addCard(cardWithModules, 'scratch')
}
modelValue.value = false // Close this

View file

@ -3,7 +3,11 @@ import type { AiriCard } from '@proj-airi/stage-ui/stores/modules/airi-card'
import DOMPurify from 'dompurify'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { useDownload } from '@proj-airi/stage-ui/composables/download'
import { exportAiriCardPackage } from '@proj-airi/stage-ui/services/airi-card-import-export'
import { useBackgroundStore } from '@proj-airi/stage-ui/stores/background'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
@ -17,8 +21,9 @@ import {
DialogRoot,
DialogTitle,
} from 'reka-ui'
import { computed, ref, watch } from 'vue'
import { computed, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import DeleteCardDialog from './DeleteCardDialog.vue'
@ -34,11 +39,13 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const { trackSceneBackgroundSet } = useAnalytics()
const cardStore = useAiriCardStore()
const consciousnessStore = useConsciousnessStore()
const speechStore = useSpeechStore()
const visionStore = useVisionStore()
const backgroundStore = useBackgroundStore()
const displayModelsStore = useDisplayModelsStore()
const { removeCard } = cardStore
const { activeCardId } = storeToRefs(cardStore)
@ -47,6 +54,7 @@ const { activeSpeechProvider: speechProvider, activeSpeechModel: defaultSpeechMo
const { activeProvider: visionProvider, activeModel: defaultVisionModel } = storeToRefs(visionStore)
const isRefreshingGallery = ref(false)
const isExportingCard = shallowRef(false)
// Get selected card data
const selectedCard = computed<AiriCard | undefined>(() => {
@ -113,6 +121,27 @@ function handleActivate() {
}, 300)
}
async function handleExportCard() {
if (!selectedCard.value)
return
isExportingCard.value = true
try {
useDownload(
await exportAiriCardPackage({ card: selectedCard.value, displayModelsStore }),
`${selectedCard.value.name.trim()}.zip`,
).download()
toast(t('settings.pages.card.exported'))
}
catch (error) {
console.error('Error exporting card package:', error)
toast(t('settings.pages.card.export_failed'))
}
finally {
isExportingCard.value = false
}
}
function highlightTagToHtml(text: string) {
return DOMPurify.sanitize(text?.replace(/\{\{(.*?)\}\}/g, '<span class="bg-primary-500/20 inline-block">{{ $1 }}</span>').trim())
}
@ -155,6 +184,7 @@ const activeBackgroundId = computed({
...selectedCard.value,
extensions: extension,
})
trackSceneBackgroundSet({ source: 'card_gallery', cleared: val === 'none' })
},
})
@ -328,6 +358,13 @@ function getModuleDisplayValue(value: string | undefined, defaultValue: string |
<!-- Action buttons -->
<div flex="~ row" gap-2>
<Button
variant="secondary"
icon="i-solar:download-minimalistic-bold-duotone"
:label="t('settings.pages.card.export')"
:disabled="isExportingCard"
@click="handleExportCard"
/>
<!-- Activation button -->
<Button
variant="primary"

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import type { ccv3 } from '@proj-airi/ccc'
import { Alert } from '@proj-airi/stage-ui/components'
import { AiriCardPackageError, importAiriCardPackage } from '@proj-airi/stage-ui/services/airi-card-import-export'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
import { InputFileCard } from '@proj-airi/ui'
import { ComboboxSelect } from '@proj-airi/ui/components/form'
@ -9,6 +9,7 @@ import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import CardCreate from './components/CardCreate.vue'
import CardCreationDialog from './components/CardCreationDialog.vue'
@ -18,6 +19,7 @@ import DeleteCardDialog from './components/DeleteCardDialog.vue'
const { t } = useI18n()
const cardStore = useAiriCardStore()
const displayModelsStore = useDisplayModelsStore()
const { addCard, removeCard } = cardStore
const { cards, activeCardId } = storeToRefs(cardStore)
@ -57,15 +59,17 @@ watch(inputFiles, async (newFiles) => {
return
try {
const content = await file.text()
const cardJSON = JSON.parse(content) as ccv3.CharacterCardV3
// Add card and select it
selectedCardId.value = addCard(cardJSON)
isCardDialogOpen.value = true
addCard(await importAiriCardPackage({ file, displayModelsStore }), 'import')
toast(t('settings.pages.card.imported'))
}
catch (error) {
console.error('Error processing card file:', error)
console.error('Error processing card package:', error)
toast(t(error instanceof AiriCardPackageError && error.code === 'missing-file'
? 'settings.pages.card.import_missing_file'
: 'settings.pages.card.import_invalid_file'))
}
finally {
inputFiles.value = []
}
})
@ -267,7 +271,7 @@ function getModuleShortName(id: string, module: 'consciousness' | 'voice') {
:class="{ 'grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 grid-auto-rows-[minmax(min-content,max-content)] grid-auto-flow-dense sm:grid-cols-[repeat(auto-fill,minmax(240px,1fr))] sm:gap-5 md:grid-cols-[repeat(auto-fill,minmax(220px,1fr))] lg:grid-cols-[repeat(auto-fill,minmax(250px,1fr))]': cards.size > 0 }"
>
<!-- Upload card -->
<InputFileCard v-model="inputFiles" accept="*.json">
<InputFileCard v-model="inputFiles" accept=".zip">
<template #default="{ isDragging }">
<template v-if="!isDragging">
<div flex flex-col items-center>

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import type { DataSettingsStatusEmits } from '../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 { shallowRef, useTemplateRef } from 'vue'
@ -10,6 +11,7 @@ import { createDataSettingsStatusHelpers } from '../status'
const emit = defineEmits<DataSettingsStatusEmits>()
const { t } = useI18n()
const { trackDataAction } = useAnalytics()
const importFileInput = useTemplateRef<HTMLInputElement>('importFileInput')
const importError = shallowRef('')
const {
@ -32,6 +34,7 @@ async function triggerExport() {
anchor.download = `airi-chat-sessions-${new Date().toISOString()}.json`
anchor.click()
URL.revokeObjectURL(url)
trackDataAction({ action: 'chats_exported' })
emitStatus(t('settings.pages.data.status.exported'))
}
catch (error) {
@ -42,6 +45,7 @@ async function triggerExport() {
function deleteChats() {
try {
deleteAllChatSessions()
trackDataAction({ action: 'chats_cleared' })
emitStatus(t('settings.pages.data.status.chats_deleted'))
}
catch (error) {
@ -60,6 +64,7 @@ async function handleImport(event: Event) {
const parsed = JSON.parse(raw) as Record<string, unknown>
await importChatSessions(parsed)
importError.value = ''
trackDataAction({ action: 'chats_imported' })
emitStatus(t('settings.pages.data.status.imported'))
}
catch (error) {

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import type { DataSettingsStatusEmits } from '../status'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { useDataMaintenance } from '@proj-airi/stage-ui/composables/use-data-maintenance'
import { DoubleCheckButton } from '@proj-airi/ui'
import { useI18n } from 'vue-i18n'
@ -9,12 +10,14 @@ import { createDataSettingsStatusHelpers } from '../status'
const emit = defineEmits<DataSettingsStatusEmits>()
const { t } = useI18n()
const { trackDataAction } = useAnalytics()
const { deleteAllData, resetProvidersSettings } = useDataMaintenance()
const { emitStatus, handleActionError } = createDataSettingsStatusHelpers(emit)
async function resetProviders() {
try {
await resetProvidersSettings()
trackDataAction({ action: 'provider_settings_reset' })
emitStatus(t('settings.pages.data.status.providers_reset'))
}
catch (error) {
@ -25,6 +28,7 @@ async function resetProviders() {
async function deleteAll() {
try {
await deleteAllData()
trackDataAction({ action: 'app_data_cleared' })
emitStatus(t('settings.pages.data.status.all_deleted'))
}
catch (error) {

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import type { DataSettingsStatusEmits } from '../status'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { useDataMaintenance } from '@proj-airi/stage-ui/composables/use-data-maintenance'
import { DoubleCheckButton } from '@proj-airi/ui'
import { useI18n } from 'vue-i18n'
@ -9,12 +10,14 @@ import { createDataSettingsStatusHelpers } from '../status'
const emit = defineEmits<DataSettingsStatusEmits>()
const { t } = useI18n()
const { trackDataAction } = useAnalytics()
const { deleteAllModels, resetModulesSettings } = useDataMaintenance()
const { emitStatus, handleActionError } = createDataSettingsStatusHelpers(emit)
async function deleteModels() {
try {
await deleteAllModels()
trackDataAction({ action: 'models_cache_cleared' })
emitStatus(t('settings.pages.data.status.models_deleted'))
}
catch (error) {
@ -25,6 +28,7 @@ async function deleteModels() {
function resetModules() {
try {
resetModulesSettings()
trackDataAction({ action: 'modules_settings_reset' })
emitStatus(t('settings.pages.data.status.modules_reset'))
}
catch (error) {

View file

@ -1,11 +1,13 @@
<script setup lang="ts">
import { Section } from '@proj-airi/stage-ui/components'
import { useAnalytics } from '@proj-airi/stage-ui/composables'
import { useAiriCardStore, useBackgroundStore } from '@proj-airi/stage-ui/stores'
import { Button, Callout } from '@proj-airi/ui'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const { trackSceneBackgroundSet } = useAnalytics()
const backgroundStore = useBackgroundStore()
const cardStore = useAiriCardStore()
@ -31,6 +33,7 @@ const activeBackgroundId = computed({
...cardStore.activeCard,
extensions: extension,
})
trackSceneBackgroundSet({ source: 'scene_settings', cleared: val === 'none' })
},
})

View file

@ -30,6 +30,7 @@
"./libs/inference": "./src/libs/inference/index.ts",
"./libs/*": "./src/libs/*.ts",
"./libs": "./src/libs/index.ts",
"./services/*": "./src/services/*.ts",
"./tools/mcp": "./src/tools/mcp.ts",
"./stores/providers/aliyun": "./src/stores/providers/aliyun/index.ts",
"./stores/analytics": "./src/stores/analytics/index.ts",
@ -124,6 +125,7 @@
"hono": "catalog:",
"html2canvas": "catalog:",
"idb-keyval": "catalog:",
"jszip": "catalog:",
"kokoro-js": "catalog:",
"localforage": "catalog:",
"mediabunny": "catalog:",

View file

@ -127,7 +127,7 @@ function confirmCreate() {
const newId = cardStore.addCard({
...structuredClone(toRaw(current)),
name,
})
}, 'duplicate')
activeCardId.value = newId
cancelCreate()

View file

@ -716,12 +716,6 @@ describe('useAnalytics conversation product events', () => {
preset_type: 'stage_model',
source: 'settings',
})
analytics.trackModelChanged({
from_model: 'gpt-old',
to_model: 'gpt-new',
provider: 'official-provider',
reason: 'manual',
})
analytics.trackProviderSwitched({
from_provider: 'openai-compatible',
to_provider: 'official-provider',
@ -777,14 +771,7 @@ describe('useAnalytics conversation product events', () => {
preset_type: 'stage_model',
source: 'settings',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(7, 'model_changed', {
surface: 'web',
from_model: 'gpt-old',
to_model: 'gpt-new',
provider: 'official-provider',
reason: 'manual',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(8, 'provider_switched', {
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(7, 'provider_switched', {
surface: 'web',
from_provider: 'openai-compatible',
to_provider: 'official-provider',
@ -792,14 +779,14 @@ describe('useAnalytics conversation product events', () => {
to_provider_type: 'official',
reason: 'manual',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(9, 'settings_changed', {
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(8, 'settings_changed', {
surface: 'web',
setting_name: 'analytics_enabled',
previous_value: false,
new_value: true,
source: 'settings',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(10, 'support_contacted', {
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(9, 'support_contacted', {
surface: 'web',
channel: 'discord',
source: 'settings',
@ -853,4 +840,106 @@ describe('useAnalytics conversation product events', () => {
entrypoint: 'community_manual_tag',
})
})
it('emits account lifecycle events shared with the ui-server-auth surface', () => {
const analytics = useAnalytics()
analytics.trackPasswordChanged()
analytics.trackPasswordResetRequested()
analytics.trackOauthProviderLinkStarted({ provider: 'github' })
analytics.trackOauthProviderUnlinked({ provider: 'google' })
analytics.trackAccountDeletionRequested()
analytics.trackOauthCallbackFailed({ stage: 'missing_flow_state' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'password_changed', { surface: 'web' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'password_reset_requested', { surface: 'web' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(
3,
'oauth_provider_link_started',
{ surface: 'web', provider: 'github' },
{ send_instantly: true, transport: 'sendBeacon' },
)
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'oauth_provider_unlinked', {
surface: 'web',
provider: 'google',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(5, 'account_deletion_requested', { surface: 'web' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(6, 'oauth_callback_failed', {
surface: 'web',
stage: 'missing_flow_state',
})
})
it('emits AIRI card edit and scene background events', () => {
const analytics = useAnalytics()
analytics.trackCardEdited({ card_id: 'card-1' })
analytics.trackSceneBackgroundSet({ source: 'card_gallery', cleared: false })
analytics.trackSceneBackgroundSet({ source: 'scene_settings', cleared: true })
analytics.trackCharacterUpdated({ character_id: 'character-1' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'card_edited', {
surface: 'web',
card_id: 'card-1',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'scene_background_set', {
surface: 'web',
source: 'card_gallery',
cleared: false,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'scene_background_set', {
surface: 'web',
source: 'scene_settings',
cleared: true,
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'character_updated', {
character_id: 'character-1',
})
})
it('emits data maintenance actions as one event with an action discriminator', () => {
const analytics = useAnalytics()
analytics.trackDataAction({ action: 'chats_exported' })
analytics.trackDataAction({ action: 'app_data_cleared' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'data_action', {
surface: 'web',
action: 'chats_exported',
})
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'data_action', {
surface: 'web',
action: 'app_data_cleared',
})
})
it('emits desktop differentiator events for spotlight, widgets, updater, MCP, and pairing', () => {
analyticsMocks.isStageTamagotchiMock.mockReturnValue(true)
const analytics = useAnalytics()
analytics.trackSpotlightUsed()
analytics.trackWidgetOpened({ widget_id: 'weather' })
analytics.trackUpdateCheckClicked({ channel: 'auto' })
analytics.trackUpdateDownloaded({ channel: 'stable', version: '0.11.0' })
analytics.trackUpdateInstallClicked({ channel: 'stable', version: '0.11.0' })
analytics.trackMcpServerAdded()
analytics.trackMcpServerRemoved()
analytics.trackMcpConnectionTestRun({ success: false })
analytics.trackDevicePairingQrShown()
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(1, 'spotlight_used')
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(2, 'widget_opened', { widget_id: 'weather' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(3, 'update_check_clicked', { channel: 'auto' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(4, 'update_downloaded', { channel: 'stable', version: '0.11.0' })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(
5,
'update_install_clicked',
{ channel: 'stable', version: '0.11.0' },
{ send_instantly: true, transport: 'sendBeacon' },
)
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(6, 'mcp_server_added')
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(7, 'mcp_server_removed')
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(8, 'mcp_connection_test_run', { success: false })
expect(analyticsMocks.posthogCaptureMock).toHaveBeenNthCalledWith(9, 'device_pairing_qr_shown')
})
})

View file

@ -37,6 +37,20 @@ export type ProductAnalyticsEntry = 'app_start' | 'onboarding' | 'settings' | 'c
export type MessageInputMode = 'text' | 'voice'
export type ConversationEventSource = 'new_session' | 'fork' | 'history' | 'share_button' | 'unknown'
/**
* Full stage vocabulary of the cross-surface `oauth_callback_failed` event.
* The web/PKCE stages fire from `pages/auth/callback.vue`; the electron
* relay stages fire from ui-server-auth's `electron-callback.vue`, which
* imports this type so the two emitters can't drift apart silently.
*/
export type OauthCallbackFailureStage
= | 'provider_error'
| 'missing_code_or_state'
| 'missing_flow_state'
| 'token_exchange_failed'
| 'parse'
| 'relay_unreachable'
interface ChatActivationBaseProperties {
provider_mode: ProviderMode
provider_id: string
@ -187,8 +201,10 @@ export function useAnalytics() {
* the regular batched queue would race the redirect and drop the
* event, which breaks the funnel.
*
* The funnel terminator `payment_completed` is emitted server-side from
* the Stripe webhook see `apps/server/src/routes/stripe/index.ts`.
* The funnel terminator `payment_completed` is forwarded to PostHog
* server-side by the product-events service (allowlist in
* `apps/server/src/services/domain/product-events.ts`), keyed by the
* Better Auth user id.
*/
function trackCheckoutStarted(planId: string, properties: { checkout_session_id?: string, price_minor_unit?: number, currency?: string }) {
if (!canCapture())
@ -215,12 +231,71 @@ export function useAnalytics() {
})
}
/** Activation funnel — step 1. */
function trackSignup(method: 'email' | 'google' | 'github' | string) {
/**
* OAuth/OIDC callback landing failed before a session existed. Stage
* values map 1:1 to the guard branches in `pages/auth/callback.vue` so
* the funnel can tell a provider-side denial from a lost PKCE state.
*/
function trackOauthCallbackFailed(properties: {
stage: Extract<OauthCallbackFailureStage, 'provider_error' | 'missing_code_or_state' | 'missing_flow_state' | 'token_exchange_failed'>
}) {
if (!canCapture())
return
posthog.capture('user_signed_up', { method })
posthog.capture('signup_completed', { source: method })
posthog.capture('oauth_callback_failed', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
// ─── Account lifecycle (same event names as apps/ui-server-auth's
// analytics module — both surfaces feed one PostHog series) ───────────
function trackPasswordChanged() {
if (!canCapture())
return
posthog.capture('password_changed', { surface: getConversationAnalyticsSurface() })
}
function trackPasswordResetRequested() {
if (!canCapture())
return
posthog.capture('password_reset_requested', { surface: getConversationAnalyticsSurface() })
}
function trackOauthProviderLinkStarted(properties: { provider: string }) {
if (!canCapture())
return
// The only caller (`useLinkedAccounts.link`) navigates to the OAuth
// consent page right after this hook — the batched queue would race
// the unload and drop the event, same as `trackCheckoutStarted`.
posthog.capture(
'oauth_provider_link_started',
{
...properties,
surface: getConversationAnalyticsSurface(),
},
{ send_instantly: true, transport: 'sendBeacon' },
)
}
function trackOauthProviderUnlinked(properties: { provider: string }) {
if (!canCapture())
return
posthog.capture('oauth_provider_unlinked', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
/**
* Deletion email sent (user confirmed in the dialog). The completion
* event lands on ui-server-auth's success page; this one is the churn
* intent signal even when the user never clicks the email link.
*/
function trackAccountDeletionRequested() {
if (!canCapture())
return
posthog.capture('account_deletion_requested', { surface: getConversationAnalyticsSurface() })
}
function trackSignupCompleted(properties: {
@ -255,19 +330,6 @@ export function useAnalytics() {
})
}
/**
* Activation funnel fires the first time a user picks a model in any
* provider settings. De-dup is intentional caller-side (we don't have a
* persistent "first model selected" flag yet); a small number of repeats
* is OK in PostHog funnels because step matching is per-distinctId, not
* per-event.
*/
function trackFirstModelSelected(modelId: string, provider: string) {
if (!canCapture())
return
posthog.capture('first_model_selected', { model_id: modelId, provider })
}
/** Retention driver — character creation is a strong D7 retention predictor. */
function trackCharacterCreated(properties: { character_type: 'built_in' | 'custom', voice_enabled: boolean }) {
if (!canCapture())
@ -312,10 +374,10 @@ export function useAnalytics() {
// ─── LLM round events (client-known fields only) ──────────────────────
// Source-of-truth for HTTP status / token usage / billing stage is the
// server (apps/server/src/routes/openai/v1) — emitting those server-side
// via captureSafe so PostHog has both perspectives merged on the same
// distinctId. These client emits supply the user-facing latency picture
// (TTFT, render time) that the server cannot see.
// server (apps/server/src/routes/openai/v1), which records them as
// Postgres `product_events` rows — deliberately NOT forwarded to PostHog
// (per-request volume stays in DB/Grafana). These client emits supply the
// user-facing latency picture (TTFT, render time) the server cannot see.
function trackMessageSendStarted(properties: { source: 'text' | 'voice', model?: string }) {
if (!canCapture())
@ -878,20 +940,6 @@ export function useAnalytics() {
})
}
function trackModelChanged(properties: {
from_model?: string
to_model: string
provider: string
reason: 'manual' | 'auto'
}) {
if (!canCapture())
return
posthog.capture('model_changed', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackOfficialTtsPreviewSucceeded(properties: Omit<TtsVoiceBaseProperties, 'source'> & {
voice_id: string
voice_type: VoiceType
@ -969,6 +1017,38 @@ export function useAnalytics() {
posthog.capture('autonomous_generate_text', properties)
}
// ─── AIRI card (ccv3 character card) events ──────────────────────────
// `card_created` is emitted store-side (`stores/modules/airi-card.ts`)
// because creation has three entry points; edit has exactly one
// user-driven entry (the creation dialog in edit mode), so it lives
// here. Background card writes (autonomous artistry, image journal,
// scene background) intentionally do NOT count as edits.
function trackCardEdited(properties: { card_id: string }) {
if (!canCapture())
return
posthog.capture('card_edited', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
/** Stage background switched on the active card. `cleared` = set to none. */
function trackSceneBackgroundSet(properties: { source: 'scene_settings' | 'card_gallery', cleared: boolean }) {
if (!canCapture())
return
posthog.capture('scene_background_set', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
function trackCharacterUpdated(properties: { character_id: string }) {
if (!canCapture())
return
posthog.capture('character_updated', properties)
}
// ─── App lifecycle ───────────────────────────────────────────────────
function trackAppLoaded(properties: { platform: 'web' | 'desktop' | 'mobile', version: string, cold_start_ms?: number }) {
@ -1058,6 +1138,87 @@ export function useAnalytics() {
})
}
// ─── Data maintenance (churn-precursor signals) ──────────────────────
/**
* One event for every destructive/exporting action on the data settings
* page. Wipes and exports often precede churn, so cohorts built on this
* event feed the at-risk-user list. Fires only after the action
* succeeded a failed wipe is not a churn signal.
*/
function trackDataAction(properties: {
action: 'chats_exported' | 'chats_imported' | 'chats_cleared' | 'app_data_cleared' | 'models_cache_cleared' | 'modules_settings_reset' | 'provider_settings_reset' | 'desktop_state_reset'
}) {
if (!canCapture())
return
posthog.capture('data_action', {
...properties,
surface: getConversationAnalyticsSurface(),
})
}
// ─── Desktop (Electron / Tamagotchi) differentiators ─────────────────
// These measure whether the desktop-only surfaces earn their upkeep:
// spotlight quick-input, floating widgets, the in-app updater, MCP
// server management. Input text never leaves the device — events carry
// counts and low-cardinality ids only.
function trackSpotlightUsed() {
if (!canCapture())
return
posthog.capture('spotlight_used')
}
function trackWidgetOpened(properties: { widget_id: string }) {
if (!canCapture())
return
posthog.capture('widget_opened', properties)
}
function trackUpdateCheckClicked(properties: { channel: string }) {
if (!canCapture())
return
posthog.capture('update_check_clicked', properties)
}
function trackUpdateDownloaded(properties: { channel: string, version?: string }) {
if (!canCapture())
return
posthog.capture('update_downloaded', properties)
}
/** User confirmed restart-and-install; the app quits right after. */
function trackUpdateInstallClicked(properties: { channel: string, version?: string }) {
if (!canCapture())
return
posthog.capture('update_install_clicked', properties, { send_instantly: true, transport: 'sendBeacon' })
}
function trackMcpServerAdded() {
if (!canCapture())
return
posthog.capture('mcp_server_added')
}
function trackMcpServerRemoved() {
if (!canCapture())
return
posthog.capture('mcp_server_removed')
}
function trackMcpConnectionTestRun(properties: { success: boolean }) {
if (!canCapture())
return
posthog.capture('mcp_connection_test_run', properties)
}
/** Pairing QR revealed — the funnel start for `device_channel_connected`. */
function trackDevicePairingQrShown() {
if (!canCapture())
return
posthog.capture('device_pairing_qr_shown')
}
// ─── Voice clone (custom TTS voice) ──────────────────────────────────
function trackVoiceCloneCreated(properties: { provider: string }) {
@ -1082,11 +1243,15 @@ export function useAnalytics() {
trackPlanSelected,
trackCheckoutStarted,
trackPaywallSeen,
trackSignup,
trackSignupCompleted,
trackOauthCallbackFailed,
trackPasswordChanged,
trackPasswordResetRequested,
trackOauthProviderLinkStarted,
trackOauthProviderUnlinked,
trackAccountDeletionRequested,
trackOnboardingStarted,
trackOnboardingCompleted,
trackFirstModelSelected,
trackCharacterCreated,
trackVoiceModeActivated,
trackModelSwitched,
@ -1146,7 +1311,6 @@ export function useAnalytics() {
trackVoicePackBound,
trackAttachmentUploaded,
trackPresetUsed,
trackModelChanged,
trackProviderSwitched,
trackSettingsChanged,
trackSupportContacted,
@ -1159,6 +1323,9 @@ export function useAnalytics() {
trackAppLoaded,
trackCardEdited,
trackSceneBackgroundSet,
trackCharacterUpdated,
trackCharacterDeleted,
trackCharacterSwitched,
trackChatSessionDeleted,
@ -1172,5 +1339,16 @@ export function useAnalytics() {
trackFeatureUsed,
trackVoiceCloneCreated,
trackDeviceChannelConnected,
trackDataAction,
trackSpotlightUsed,
trackWidgetOpened,
trackUpdateCheckClicked,
trackUpdateDownloaded,
trackUpdateInstallClicked,
trackMcpServerAdded,
trackMcpServerRemoved,
trackMcpConnectionTestRun,
trackDevicePairingQrShown,
}
}

View file

@ -52,4 +52,75 @@ describe('useLinkedAccounts', () => {
errorCallbackURL: 'https://accounts.airi.build/ui/profile',
})
})
it('fires analytics hooks on unlink success and link handoff, but not on failure', async () => {
const onUnlinked = vi.fn()
const onLinkStarted = vi.fn()
const unlinkAccount = vi.fn(async (): Promise<{ data: unknown, error: { message?: string } | null }> => ({ data: null, error: null }))
const linkSocial = vi.fn(async (): Promise<{ data: { url?: string, redirect?: boolean, status?: boolean } | null, error: { message?: string } | null }> => ({
data: { status: true, redirect: false },
error: null,
}))
const holder: {
linkedAccounts?: ReturnType<typeof useLinkedAccounts>
} = {}
const app = createSSRApp({
setup() {
holder.linkedAccounts = useLinkedAccounts({
client: {
// Two rows so `isLastSignInMethod` doesn't veto the unlink.
listAccounts: vi.fn(async () => ({
data: [
{ id: '1', accountId: 'a-1', providerId: 'github', createdAt: '2026-01-01T00:00:00Z', scopes: [] },
{ id: '2', accountId: 'a-2', providerId: 'credential', createdAt: '2026-01-01T00:00:00Z', scopes: [] },
],
error: null,
})),
unlinkAccount,
linkSocial,
},
isAuthenticated: ref(false),
describeError: () => 'boom',
buildCallbackURL: () => 'https://accounts.airi.build/ui/profile',
messages: {
listFailed: 'list failed',
unlinkFailed: 'unlink failed',
linkFailed: 'link failed',
lastAccount: 'last account',
unlinked: provider => `${provider} unlinked`,
linkStarted: provider => `${provider} link started`,
},
onUnlinked,
onLinkStarted,
})
return () => null
},
})
await renderToString(app)
if (!holder.linkedAccounts)
throw new Error('Expected linked accounts composable to initialize')
await holder.linkedAccounts.refresh()
await holder.linkedAccounts.unlink('github', 'GitHub')
expect(onUnlinked).toHaveBeenCalledTimes(1)
expect(onUnlinked).toHaveBeenCalledWith('github')
await holder.linkedAccounts.link('google', 'Google')
expect(onLinkStarted).toHaveBeenCalledTimes(1)
expect(onLinkStarted).toHaveBeenCalledWith('google')
// Failure paths must not fire the hooks — a failed unlink is not an
// unlink, and a failed handoff never reached the provider.
unlinkAccount.mockResolvedValueOnce({ data: null, error: { message: 'nope' } })
await holder.linkedAccounts.unlink('github', 'GitHub')
expect(onUnlinked).toHaveBeenCalledTimes(1)
linkSocial.mockResolvedValueOnce({ data: null, error: { message: 'nope' } })
await holder.linkedAccounts.link('google', 'Google')
expect(onLinkStarted).toHaveBeenCalledTimes(1)
})
})

View file

@ -76,6 +76,21 @@ export interface UseLinkedAccountsArgs {
* and hash-history routers without further configuration.
*/
buildCallbackURL?: () => string
/**
* Analytics hook fires exactly once per successful unlink, after the
* server confirmed the removal. Success is only knowable inside this
* composable (errors are swallowed into the `error` ref for the UI), so
* callers that need to observe it must hook here instead of inspecting
* refs after `unlink()` resolves.
*/
onUnlinked?: (providerId: string) => void
/**
* Analytics hook fires right before the OAuth consent redirect
* navigates away (or after a synchronous link succeeded). Link
* completion happens on the provider's site and is not observable
* from this page; treat this as "link attempt handed off".
*/
onLinkStarted?: (providerId: string) => void
}
/**
@ -173,6 +188,7 @@ export function useLinkedAccounts(args: UseLinkedAccountsArgs) {
if (apiError)
throw new Error(apiError.message ?? 'unlinkAccount failed')
message.value = args.messages.unlinked(providerName)
args.onUnlinked?.(providerId)
await refresh()
}
catch (err) {
@ -201,11 +217,13 @@ export function useLinkedAccounts(args: UseLinkedAccountsArgs) {
if (apiError)
throw new Error(apiError.message ?? 'linkSocial failed')
if (data?.url) {
args.onLinkStarted?.(providerId)
window.location.assign(data.url)
return
}
// No URL came back (e.g. provider returned success synchronously) —
// refresh so the new row shows up without a navigation.
args.onLinkStarted?.(providerId)
await refresh()
}
catch (err) {

View file

@ -0,0 +1,123 @@
import type { ccv3 } from '@proj-airi/ccc'
import type { AiriCard, AiriExtension } from '../stores/modules/airi-card'
import JSZip from 'jszip'
import { exportToJSON } from '@proj-airi/ccc'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DisplayModelFormat, useDisplayModelsStore } from '../stores/display-models'
import { exportAiriCardPackage, importAiriCardPackage } from './airi-card-import-export'
describe('airi card package import/export', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.unstubAllGlobals()
})
it('exports sanitized packages and restores display models', async () => {
const displayModelsStore = useDisplayModelsStore()
const fetch = vi.fn(async () => new Response('preset-vrm-model'))
vi.stubGlobal('fetch', fetch)
vi.spyOn(displayModelsStore, 'getDisplayModel').mockResolvedValue({
id: 'preset-vrm-1',
format: DisplayModelFormat.VRM,
type: 'url' as const,
url: '/assets/avatar.vrm',
name: 'AvatarSample_A',
importedAt: 1,
})
mockAddDisplayModel(displayModelsStore, 'display-model-imported')
const exported = await exportAiriCardPackage({ card: createCard(), displayModelsStore })
const zip = await JSZip.loadAsync(await exported.arrayBuffer())
const cardJson = await readJson<ccv3.CharacterCardV3>(zip, 'card.json')
const imported = await importAiriCardPackage({ file: new File([exported], 'card.zip'), displayModelsStore })
const airi = airiFrom(cardJson)
expect(fetch).toHaveBeenCalledWith('/assets/avatar.vrm')
expect(await readJson(zip, 'manifest.json')).toMatchObject({ format: 'airi-character-card', version: 1, resources: { displayModel: { path: 'models/body-model.vrm', format: DisplayModelFormat.VRM, name: 'AvatarSample_A.vrm' } } })
expect(await zip.file('models/body-model.vrm')?.async('string')).toBe('preset-vrm-model')
expect(cardJson.data).toMatchObject({ name: 'AIRI / Test Card', creator: '', tags: [], mes_example: '' })
expect(airi.modules).toMatchObject({ consciousness: { provider: 'openai', model: 'gpt-4o' }, speech: { provider: 'elevenlabs', model: 'eleven', voice_id: 'alloy' } })
expect(airi.modules).not.toHaveProperty('activeBackgroundId')
expect(airi.modules.artistry).not.toHaveProperty('workflowId')
expect(airi.agents).toEqual({})
expect(displayModelsStore.addDisplayModel).toHaveBeenCalledWith(DisplayModelFormat.VRM, expect.objectContaining({ name: 'AvatarSample_A.vrm' }))
expect(airiFrom(imported).modules.displayModelId).toBe('display-model-imported')
})
it('classifies invalid packages', async () => {
const emptyZip = new JSZip()
const invalidJsonZip = new JSZip()
const displayModelsStore = useDisplayModelsStore()
invalidJsonZip.file('manifest.json', '{')
const cases = [
[new File(['not zip'], 'card.zip'), { code: 'invalid-file', message: 'Invalid zip file' }],
[new File([await emptyZip.generateAsync({ type: 'arraybuffer' })], 'empty.zip'), { code: 'missing-file' }],
[new File([await invalidJsonZip.generateAsync({ type: 'arraybuffer' })], 'invalid-json.zip'), { cause: expect.any(SyntaxError), code: 'invalid-file' }],
[await packageFile(exportToJSON(createCard()), { version: 2 }), { code: 'invalid-file' }],
] as const
for (const [file, expected] of cases)
await expect(importAiriCardPackage({ file, displayModelsStore })).rejects.toMatchObject(expected)
})
})
function mockAddDisplayModel(store: ReturnType<typeof useDisplayModelsStore>, id = 'unused') {
return vi.spyOn(store, 'addDisplayModel').mockImplementation(async (format, file) => ({
id,
format,
type: 'file' as const,
file,
name: file.name,
importedAt: 1,
}))
}
function createCard(displayModelId = 'preset-vrm-1'): AiriCard {
return {
name: 'AIRI / Test Card',
nickname: 'Tester',
version: '1.2.3',
description: 'Description',
creator: 'Hidden creator',
messageExample: [['{{user}}: hidden']],
tags: ['hidden'],
extensions: {
airi: {
modules: {
consciousness: { provider: 'openai', model: 'gpt-4o' },
vision: { provider: 'ollama', model: 'llava' },
speech: { provider: 'elevenlabs', model: 'eleven', voice_id: 'alloy', pitch: 1 },
displayModelId,
activeBackgroundId: 'background-secret',
artistry: { provider: 'replicate', model: 'flux', workflowId: 'workflow-secret' },
},
agents: { minecraft: { prompt: 'secret', enabled: true } },
},
},
}
}
async function packageFile(cardJson: ccv3.CharacterCardV3, manifestOverrides: Record<string, unknown> = {}) {
const zip = new JSZip()
zip.file('manifest.json', JSON.stringify({
format: 'airi-character-card',
version: 1,
card: { path: 'card.json', spec: 'chara_card_v3' },
...manifestOverrides,
}))
zip.file('card.json', JSON.stringify(cardJson))
return new File([await zip.generateAsync({ type: 'arraybuffer' })], 'card.zip')
}
async function readJson<T = Record<string, unknown>>(zip: JSZip, path: string): Promise<T> {
return JSON.parse(await zip.file(path)!.async('string')) as T
}
function airiFrom(card: ccv3.CharacterCardV3): AiriExtension {
return card.data.extensions.airi as AiriExtension
}

View file

@ -0,0 +1,269 @@
import type { Card, ccv3 } from '@proj-airi/ccc'
import type { GenericSchema, InferOutput } from 'valibot'
import type { DisplayModel, useDisplayModelsStore } from '../stores/display-models'
import type { AiriCard, AiriExtension } from '../stores/modules/airi-card'
import JSZip from 'jszip'
import { exportToJSON } from '@proj-airi/ccc'
import { array, literal, object, optional, parse, picklist, record, string, unknown as unknownSchema } from 'valibot'
import { DisplayModelFormat } from '../stores/display-models'
const FORMAT = 'airi-character-card'
const VERSION = 1
const CARD_PATH = 'card.json'
const MANIFEST_PATH = 'manifest.json'
const MODEL_EXT: Partial<Record<DisplayModelFormat, string>> = {
[DisplayModelFormat.Live2dZip]: 'zip',
[DisplayModelFormat.SpineZip]: 'zip',
[DisplayModelFormat.VRM]: 'vrm',
}
type DisplayModelsStore = ReturnType<typeof useDisplayModelsStore>
type ExportableCard = Card & { extensions: { airi: AiriExtension } }
const manifestSchema = object({
format: literal(FORMAT),
version: literal(VERSION),
card: object({ path: literal(CARD_PATH), spec: literal('chara_card_v3') }),
resources: optional(object({
displayModel: object({
path: string(),
format: picklist([DisplayModelFormat.Live2dZip, DisplayModelFormat.SpineZip, DisplayModelFormat.VRM]),
name: string(),
}),
})),
})
const characterCardV3Schema = object({
spec: literal('chara_card_v3'),
spec_version: literal('3.0'),
data: object({
name: string(),
nickname: optional(string()),
character_version: optional(string(), '1.0.0'),
description: optional(string(), ''),
personality: optional(string(), ''),
scenario: optional(string(), ''),
first_mes: optional(string(), ''),
alternate_greetings: optional(array(string()), []),
creator_notes: optional(string(), ''),
system_prompt: optional(string(), ''),
post_history_instructions: optional(string(), ''),
extensions: optional(record(string(), unknownSchema()), {}),
}),
})
type CharacterCardPackageJson = InferOutput<typeof characterCardV3Schema>
type AiriCardPackageErrorCode = 'missing-file' | 'invalid-file'
type Manifest = InferOutput<typeof manifestSchema>
export class AiriCardPackageError extends Error {
constructor(public readonly code: AiriCardPackageErrorCode, message: string, options?: { cause?: unknown }) {
super(message, options)
this.name = 'AiriCardPackageError'
}
}
/** Exports only the creation/edit form whitelist; provider globals and runtime state are never cloned. */
export async function exportAiriCardPackage({ card, displayModelsStore }: { card: AiriCard, displayModelsStore: DisplayModelsStore }): Promise<Blob> {
const exportableCard = cardFromAiriCard(card)
const displayModel = await exportDisplayModel(exportableCard, displayModelsStore)
const manifest = {
format: FORMAT,
version: VERSION,
createdAt: new Date().toISOString(),
card: { path: CARD_PATH, spec: 'chara_card_v3' },
...(displayModel ? { resources: { displayModel: displayModel.manifest } } : {}),
}
const zip = new JSZip()
zip.file(MANIFEST_PATH, JSON.stringify(manifest, null, 2))
zip.file(CARD_PATH, JSON.stringify(exportToJSON(exportableCard), null, 2))
if (displayModel)
zip.file(displayModel.manifest.path, displayModel.data)
return zip.generateAsync({ type: 'blob' })
}
/** Imports a package as sanitized CCv3 JSON; edited zip payloads cannot smuggle extra AIRI fields through. */
export async function importAiriCardPackage({ file, displayModelsStore }: { file: File, displayModelsStore: DisplayModelsStore }): Promise<ccv3.CharacterCardV3> {
const zip = await loadZip(file)
const manifest = await readJsonFile(zip, MANIFEST_PATH, manifestSchema)
const cardJson = await readJsonFile(zip, manifest.card.path, characterCardV3Schema)
const displayModelId = await importDisplayModel(zip, manifest, displayModelsStore)
return exportToJSON(cardFromCharacterCard(cardJson, displayModelId))
}
async function exportDisplayModel(card: ExportableCard, store: DisplayModelsStore) {
const displayModelId = card.extensions.airi.modules.displayModelId
if (!displayModelId)
return
const model = await store.getDisplayModel(displayModelId)
if (!model) {
if (displayModelId.startsWith('display-model-'))
throw error('invalid-file', 'Missing local display model')
return
}
const modelExt = MODEL_EXT[model.format]
if (!modelExt)
throw error('invalid-file', 'Unsupported or empty local display model')
const payload = await displayModelPayload(model)
return {
data: payload.data,
manifest: {
format: model.format,
name: payload.file.name,
path: `models/body-model.${modelExt}`,
},
}
}
async function importDisplayModel(zip: JSZip, manifest: Manifest, store: DisplayModelsStore) {
const resource = manifest.resources?.displayModel
if (!resource)
return
const file = zip.file(resource.path)
if (!file)
throw error('missing-file', 'Missing display model file')
try {
const data = await file.async('arraybuffer')
return (await store.addDisplayModel(resource.format, new File([data], resource.name))).id
}
catch (cause) {
throw error('invalid-file', 'Failed to import display model file', { cause })
}
}
async function loadZip(file: File) {
try {
return await JSZip.loadAsync(await file.arrayBuffer())
}
catch (cause) {
throw error('invalid-file', 'Invalid zip file', { cause })
}
}
async function readJsonFile<S extends GenericSchema>(zip: JSZip, path: string, schema: S): Promise<InferOutput<S>> {
const file = zip.file(path)
if (!file)
throw error('missing-file', `Missing ${path}`)
try {
return parse(schema, JSON.parse(await file.async('string')))
}
catch (cause) {
throw error('invalid-file', `Invalid ${path}`, { cause })
}
}
function cardFromAiriCard(card: AiriCard): ExportableCard {
return {
name: card.name,
nickname: card.nickname,
version: card.version,
description: card.description ?? '',
personality: card.personality ?? '',
scenario: card.scenario ?? '',
greetings: card.greetings ?? [],
notes: card.notes ?? '',
systemPrompt: card.systemPrompt ?? '',
postHistoryInstructions: card.postHistoryInstructions ?? '',
extensions: { airi: sanitizeAiri(card.extensions?.airi) },
}
}
function cardFromCharacterCard(card: CharacterCardPackageJson, displayModelId?: string): ExportableCard {
const data = card.data
return {
name: data.name,
nickname: data.nickname,
version: data.character_version,
description: data.description,
personality: data.personality,
scenario: data.scenario,
greetings: [data.first_mes, ...(data.alternate_greetings ?? [])],
notes: data.creator_notes,
systemPrompt: data.system_prompt,
postHistoryInstructions: data.post_history_instructions,
extensions: { airi: sanitizeAiri(data.extensions?.airi, displayModelId) },
}
}
function sanitizeAiri(value: unknown, displayModelIdOverride?: string): AiriExtension {
const source = isRecord(value) ? value : {}
const modules = isRecord(source.modules) ? source.modules : {}
const artistry = isRecord(modules.artistry) ? modules.artistry : {}
const speech = isRecord(modules.speech) ? modules.speech : {}
const displayModelId = displayModelIdOverride ?? stringValue(modules.displayModelId)
return {
modules: {
consciousness: providerModel(modules.consciousness),
vision: providerModel(modules.vision),
speech: {
...providerModel(modules.speech),
voice_id: stringValue(speech.voice_id),
},
...(displayModelId ? { displayModelId } : {}),
artistry: {
...(typeof artistry.provider === 'string' ? { provider: artistry.provider } : {}),
...(typeof artistry.model === 'string' ? { model: artistry.model } : {}),
...(typeof artistry.promptPrefix === 'string' ? { promptPrefix: artistry.promptPrefix } : {}),
...(typeof artistry.widgetInstruction === 'string' ? { widgetInstruction: artistry.widgetInstruction } : {}),
...(isSpawnMode(artistry.spawnMode) ? { spawnMode: artistry.spawnMode } : {}),
...(isRecord(artistry.options) ? { options: artistry.options } : {}),
...(typeof artistry.autonomousEnabled === 'boolean' ? { autonomousEnabled: artistry.autonomousEnabled } : {}),
...(typeof artistry.autonomousThreshold === 'number' ? { autonomousThreshold: artistry.autonomousThreshold } : {}),
},
},
agents: {},
}
}
function providerModel(value: unknown) {
const source = isRecord(value) ? value : {}
return { provider: stringValue(source.provider), model: stringValue(source.model) }
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function stringValue(value: unknown) {
return typeof value === 'string' ? value : ''
}
function isSpawnMode(value: unknown): value is NonNullable<AiriExtension['modules']['artistry']>['spawnMode'] {
return value === 'bg' || value === 'widget' || value === 'inline' || value === 'bg_widget'
}
async function displayModelPayload(model: DisplayModel): Promise<{ data: ArrayBuffer, file: File }> {
try {
const response = model.type === 'url' ? await fetch(model.url) : undefined
if (response && !response.ok)
throw new Error(`Failed to read display model URL: ${response.status} ${response.statusText}`)
const file = model.type === 'file' ? model.file : new File([await response!.blob()], `${model.name}.${MODEL_EXT[model.format]}`)
if (file.size <= 0)
throw new Error('Display model file is empty')
return { data: await file.arrayBuffer(), file }
}
catch (cause) {
throw error('invalid-file', 'Failed to read display model file', { cause })
}
}
function error(code: AiriCardPackageErrorCode, message: string, options?: { cause?: unknown }) {
return new AiriCardPackageError(code, message, options)
}

View file

@ -88,9 +88,9 @@ export function registerPosthogBuildInfo(buildInfo: AboutBuildInfo): void {
* land on the anonymous device person, PostHog cannot join them.
*
* Expects:
* - `userId` is the Better Auth user id (`user.id`) must match what
* `apps/server/src/routes/stripe/index.ts` passes as `distinctId` in
* `capturePaymentCompleted`.
* - `userId` is the Better Auth user id (`user.id`) the same value the
* server-side product-events forwarder passes as `distinctId` (see
* `apps/server/src/services/domain/product-events.ts`).
*/
export function identifyPosthogUser(userId: string): void {
if (!posthogInitialized || posthog.has_opted_out_capturing())

View file

@ -34,6 +34,7 @@ describe('stage-ui exports contract', () => {
'./libs/*',
'./libs/inference',
'./libs/inference/adapters/*',
'./services/*',
'./stores',
'./stores/*',
'./stores/analytics',
@ -62,6 +63,7 @@ describe('stage-ui exports contract', () => {
expect(exportsMap['./stores']).toBe('./src/stores/index.ts')
expect(exportsMap['./stores/*']).toBe('./src/stores/*.ts')
expect(exportsMap['./services/*']).toBe('./src/services/*.ts')
expect(exportsMap['./tools/mcp']).toBe('./src/tools/mcp.ts')
expect(exportsMap['./types']).toBe('./src/types/index.ts')
expect(exportsMap['./types/*']).toBe('./src/types/*.ts')

View file

@ -115,9 +115,16 @@ export const useAiriCardStore = defineStore('airi-card', () => {
activeSpeechModel,
} = storeToRefs(speechStore)
const addCard = (card: AiriCard | Card | ccv3.CharacterCardV3) => {
/**
* `source` feeds the `card_created` analytics event: `scratch` = built in
* the creation dialog, `import` = ccv3 JSON upload, `duplicate` = cloned
* from an existing card (profile switcher). Required so a new call site
* can't silently degrade creation attribution.
*/
const addCard = (card: AiriCard | Card | ccv3.CharacterCardV3, source: 'scratch' | 'import' | 'duplicate') => {
const newCardId = nanoid()
cards.value.set(newCardId, newAiriCard(card))
capturePosthogEvent('card_created', { card_id: newCardId, source })
return newCardId
}

387
pnpm-lock.yaml generated
View file

@ -873,6 +873,9 @@ catalogs:
posthog-js:
specifier: 1.306.1
version: 1.306.1
posthog-node:
specifier: ^5.39.4
version: 5.39.4
postprocessing:
specifier: ^6.39.1
version: 6.39.1
@ -971,7 +974,7 @@ catalogs:
version: 0.184.0
tinyexec:
specifier: ^1.1.1
version: 1.1.1
version: 1.2.4
tinyglobby:
specifier: ^0.2.16
version: 0.2.16
@ -1287,7 +1290,7 @@ importers:
version: 19.11.0
tinyexec:
specifier: 'catalog:'
version: 1.1.1
version: 1.2.4
tsdown:
specifier: 'catalog:'
version: 0.21.9(@arethetypeswrong/core@0.18.2)(oxc-resolver@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))(publint@0.3.18)(synckit@0.11.12)(typescript@5.9.3)(unplugin-unused@0.5.7)(vue-tsc@3.2.6(typescript@5.9.3))
@ -1523,6 +1526,9 @@ importers:
pg:
specifier: 'catalog:'
version: 8.20.0
posthog-node:
specifier: 'catalog:'
version: 5.39.4(rxjs@7.8.2)
resend:
specifier: 'catalog:'
version: 6.12.2
@ -2257,7 +2263,7 @@ importers:
version: 3.0.2(electron@41.2.1)
'@electron-toolkit/tsconfig':
specifier: 'catalog:'
version: 2.0.0(@types/node@25.6.0)
version: 2.0.0(@types/node@24.12.2)
'@electron-toolkit/utils':
specifier: 'catalog:'
version: 4.0.0(electron@41.2.1)
@ -2296,7 +2302,7 @@ importers:
version: 3.1.0
'@intlify/unplugin-vue-i18n':
specifier: 'catalog:'
version: 11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
version: 11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@modelcontextprotocol/sdk':
specifier: 'catalog:'
version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)
@ -2332,10 +2338,10 @@ importers:
version: link:../../packages/ui-transitions
'@proj-airi/unplugin-fetch':
specifier: 'catalog:'
version: 0.2.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 0.2.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@proj-airi/unplugin-live2d-sdk':
specifier: 'catalog:'
version: 0.1.7(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
version: 0.1.7(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@types/audioworklet':
specifier: 'catalog:'
version: 0.0.97
@ -2362,7 +2368,7 @@ importers:
version: 2.10.3
'@vitejs/plugin-vue':
specifier: 'catalog:'
version: 6.0.6(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))
version: 6.0.6(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))
'@vue-macros/volar':
specifier: 'catalog:'
version: 3.1.2(typescript@5.9.3)(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3))
@ -2395,7 +2401,7 @@ importers:
version: 6.8.3
electron-vite:
specifier: 'catalog:'
version: 5.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 5.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
get-port-please:
specifier: 'catalog:'
version: 3.2.0
@ -2416,31 +2422,31 @@ importers:
version: 2.2.6
unocss-preset-scrollbar:
specifier: 'catalog:'
version: 4.0.0(unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))
version: 4.0.0(unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))
unplugin-info:
specifier: 'catalog:'
version: 1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
unplugin-yaml:
specifier: 'catalog:'
version: 4.1.0(@nuxt/kit@3.20.2(magicast@0.5.2))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 4.1.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite:
specifier: 'catalog:'
version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
version: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-bundle-visualizer:
specifier: 'catalog:'
version: 1.2.1(rolldown@1.0.0-rc.16)(rollup@4.60.1)
vite-plugin-mkcert:
specifier: 'catalog:'
version: 2.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
version: 2.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite-plugin-vue-devtools:
specifier: 'catalog:'
version: 8.1.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))
version: 8.1.1(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))
vite-plugin-vue-layouts:
specifier: 'catalog:'
version: 0.11.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
version: 0.11.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
vue-macros:
specifier: 'catalog:'
version: 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3))
version: 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3))
vue-tsc:
specifier: 'catalog:'
version: 3.2.6(typescript@5.9.3)
@ -3204,7 +3210,7 @@ importers:
version: 5.1.11
tinyexec:
specifier: 'catalog:'
version: 1.1.1
version: 1.2.4
vscode-ext-gen:
specifier: 'catalog:'
version: 1.6.0
@ -3219,7 +3225,7 @@ importers:
version: 7.0.0
tinyexec:
specifier: 'catalog:'
version: 1.1.1
version: 1.2.4
packages/audio:
dependencies:
@ -3277,7 +3283,7 @@ importers:
version: 7.0.0
tinyexec:
specifier: 'catalog:'
version: 1.1.1
version: 1.2.4
vite:
specifier: ^7.0.0 || ^8.0.0-beta.0
version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
@ -4181,6 +4187,9 @@ importers:
idb-keyval:
specifier: 'catalog:'
version: 6.2.2
jszip:
specifier: 'catalog:'
version: 3.10.1
kokoro-js:
specifier: 'catalog:'
version: 1.2.1
@ -9543,9 +9552,15 @@ packages:
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@posthog/core@1.39.6':
resolution: {integrity: sha512-o6ajIwN5zXoNP0D4H/QPmOyibNTUkSyOR6ya7AG5U2ywXx4awo72L2KnCoiZPQM5x/bXv6jPBdimH8M18Ax0aw==}
'@posthog/core@1.7.1':
resolution: {integrity: sha512-kjK0eFMIpKo9GXIbts8VtAknsoZ18oZorANdtuTj1CbgS28t4ZVq//HAWhnxEuXRTrtkd+SUJ6Ux3j2Af8NCuA==}
'@posthog/types@1.392.1':
resolution: {integrity: sha512-Qg6Gl7/1vlr8+gPtBi5gwnLgAgiyFoKOVmTvTtDcvya9cpTwZfna7rQmkGQ4B63CunUYNNbOlqcwiUwUDyTK6w==}
'@prisma/client@5.22.0':
resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==}
engines: {node: '>=16.13'}
@ -13539,9 +13554,6 @@ packages:
peerDependencies:
eslint: ^9.5.0 || ^10.0.0
eslint-flat-config-utils@3.1.0:
resolution: {integrity: sha512-lM+Nwo2CzpuTS/RASQExlEIwk/BQoKqJWX6VbDlLMb/mveqvt9MMrRXFEkG3bseuK6g8noKZLeX82epkILtv4A==}
eslint-flat-config-utils@3.2.0:
resolution: {integrity: sha512-PHgo1X5uqIorJONLVD9BIaOSdoYFD3z/AeJljdqDPlWVRpeCYkDbK9k0AXoYVqqNJr6FEYIEr5Rm2TSktLQcHw==}
@ -13630,12 +13642,6 @@ packages:
peerDependencies:
eslint: ^8.45.0 || ^9.0.0 || ^10.0.0
eslint-plugin-perfectionist@5.8.0:
resolution: {integrity: sha512-k8uIptWIxkUclonCFGyDzgYs9NI+Qh0a7cUXS3L7IYZDEsjXuimFBVbxXPQQngWqMiaxJRwbtYB4smMGMqF+cw==}
engines: {node: ^20.0.0 || >=22.0.0}
peerDependencies:
eslint: ^8.45.0 || ^9.0.0 || ^10.0.0
eslint-plugin-pnpm@1.6.0:
resolution: {integrity: sha512-dxmt9r3zvPaft6IugS4i0k16xag3fTbOvm/road5uV9Y8qUCQT0xzheSh3gMlYAlC6vXRpfArBDsTZ7H7JKCbg==}
peerDependencies:
@ -16533,6 +16539,15 @@ packages:
posthog-js@1.306.1:
resolution: {integrity: sha512-wO7bliv/5tlAlfoKCUzwkGXZVNexk0dHigMf9tNp0q1rzs62wThogREY7Tz7h/iWKYiuXy1RumtVlTmHuBXa1w==}
posthog-node@5.39.4:
resolution: {integrity: sha512-+fCQ7htBFRQQFbIzl1T0TA7bDwYyaB9XP308ZFMCUoB5LzTzOFxBa6TYVrxdH/VQl43WXTp6sf0QsG2Z4XlNBg==}
engines: {node: ^20.20.0 || >=22.22.0}
peerDependencies:
rxjs: ^7.0.0
peerDependenciesMeta:
rxjs:
optional: true
postject@1.0.0-alpha.6:
resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==}
engines: {node: '>=14.0.0'}
@ -17751,10 +17766,6 @@ packages:
tinycolor2@1.6.0:
resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==}
tinyexec@1.1.1:
resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==}
engines: {node: '>=18'}
tinyexec@1.2.4:
resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
engines: {node: '>=18'}
@ -19300,7 +19311,7 @@ snapshots:
cac: 7.0.0
eslint: 10.2.1(jiti@2.6.1)
eslint-config-flat-gitignore: 2.3.0(eslint@10.2.1(jiti@2.6.1))
eslint-flat-config-utils: 3.1.0
eslint-flat-config-utils: 3.2.0
eslint-merge-processors: 2.0.0(eslint@10.2.1(jiti@2.6.1))
eslint-plugin-antfu: 3.2.2(eslint@10.2.1(jiti@2.6.1))
eslint-plugin-command: 3.5.2(@typescript-eslint/rule-tester@8.56.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.63.0(typescript@5.9.3))(@typescript-eslint/utils@8.63.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))
@ -19309,7 +19320,7 @@ snapshots:
eslint-plugin-jsonc: 3.1.2(eslint@10.2.1(jiti@2.6.1))
eslint-plugin-n: 17.24.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
eslint-plugin-no-only-tests: 3.3.0
eslint-plugin-perfectionist: 5.8.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
eslint-plugin-perfectionist: 5.10.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
eslint-plugin-pnpm: 1.6.0(eslint@10.2.1(jiti@2.6.1))
eslint-plugin-regexp: 3.1.0(eslint@10.2.1(jiti@2.6.1))
eslint-plugin-toml: 1.3.1(eslint@10.2.1(jiti@2.6.1))
@ -19340,13 +19351,13 @@ snapshots:
'@antfu/install-pkg@1.1.0':
dependencies:
package-manager-detector: 1.6.0
tinyexec: 1.1.1
tinyexec: 1.2.4
'@antfu/ni@30.0.0':
dependencies:
fzf: 0.5.2
package-manager-detector: 1.6.0
tinyexec: 1.1.1
tinyexec: 1.2.4
tinyglobby: 0.2.16
'@anthropic-ai/claude-code-darwin-arm64@2.1.114':
@ -20730,9 +20741,9 @@ snapshots:
dependencies:
electron: 41.2.1
'@electron-toolkit/tsconfig@2.0.0(@types/node@25.6.0)':
'@electron-toolkit/tsconfig@2.0.0(@types/node@24.12.2)':
dependencies:
'@types/node': 25.6.0
'@types/node': 24.12.2
'@electron-toolkit/utils@4.0.0(electron@41.2.1)':
dependencies:
@ -20864,7 +20875,7 @@ snapshots:
'@es-joy/jsdoccomment@0.84.0':
dependencies:
'@types/estree': 1.0.8
'@typescript-eslint/types': 8.58.1
'@typescript-eslint/types': 8.63.0
comment-parser: 1.4.5
esquery: 1.7.0
jsdoc-type-pratt-parser: 7.1.1
@ -20872,7 +20883,7 @@ snapshots:
'@es-joy/jsdoccomment@0.86.0':
dependencies:
'@types/estree': 1.0.8
'@typescript-eslint/types': 8.58.1
'@typescript-eslint/types': 8.63.0
comment-parser: 1.4.6
esquery: 1.7.0
jsdoc-type-pratt-parser: 7.2.0
@ -21624,8 +21635,8 @@ snapshots:
'@intlify/shared': 11.3.2
'@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.3.2)(@vue/compiler-dom@3.5.32)(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@rollup/pluginutils': 5.3.0(rollup@2.80.0)
'@typescript-eslint/scope-manager': 8.58.1
'@typescript-eslint/typescript-estree': 8.58.1(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.63.0
'@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3)
debug: 4.4.3(supports-color@10.2.2)
fast-glob: 3.3.3
pathe: 2.0.3
@ -21642,6 +21653,31 @@ snapshots:
- supports-color
- typescript
'@intlify/unplugin-vue-i18n@11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1))
'@intlify/bundle-utils': 11.0.7(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))
'@intlify/shared': 11.3.2
'@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.3.2)(@vue/compiler-dom@3.5.32)(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
'@typescript-eslint/scope-manager': 8.63.0
'@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3)
debug: 4.4.3(supports-color@10.2.2)
fast-glob: 3.3.3
pathe: 2.0.3
picocolors: 1.1.1
unplugin: 2.3.11
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vue: 3.5.32(typescript@5.9.3)
optionalDependencies:
vue-i18n: 11.3.2(vue@3.5.32(typescript@5.9.3))
transitivePeerDependencies:
- '@vue/compiler-dom'
- eslint
- rollup
- supports-color
- typescript
'@intlify/unplugin-vue-i18n@11.0.7(@vue/compiler-dom@3.5.32)(eslint@10.2.1(jiti@2.6.1))(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1))
@ -21649,8 +21685,8 @@ snapshots:
'@intlify/shared': 11.3.2
'@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.3.2)(@vue/compiler-dom@3.5.32)(vue-i18n@11.3.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
'@typescript-eslint/scope-manager': 8.58.1
'@typescript-eslint/typescript-estree': 8.58.1(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.63.0
'@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3)
debug: 4.4.3(supports-color@10.2.2)
fast-glob: 3.3.3
pathe: 2.0.3
@ -23585,10 +23621,16 @@ snapshots:
'@polka/url@1.0.0-next.29': {}
'@posthog/core@1.39.6':
dependencies:
'@posthog/types': 1.392.1
'@posthog/core@1.7.1':
dependencies:
cross-spawn: 7.0.6
'@posthog/types@1.392.1': {}
'@prisma/client@5.22.0': {}
'@proj-airi/chromatic@1.1.1':
@ -23646,11 +23688,35 @@ snapshots:
transitivePeerDependencies:
- magicast
'@proj-airi/unplugin-fetch@0.2.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
ofetch: 1.5.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@proj-airi/unplugin-fetch@0.2.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
ofetch: 1.5.1
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
'@proj-airi/unplugin-live2d-sdk@0.1.7(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)':
dependencies:
ofetch: 1.5.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
yauzl: 3.3.0
transitivePeerDependencies:
- '@types/node'
- '@vitejs/devtools'
- esbuild
- jiti
- less
- sass
- sass-embedded
- stylus
- sugarss
- terser
- tsx
- yaml
'@proj-airi/unplugin-live2d-sdk@0.1.7(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)':
dependencies:
ofetch: 1.5.1
@ -24147,7 +24213,7 @@ snapshots:
'@stylistic/eslint-plugin@5.10.0(eslint@10.2.1(jiti@2.6.1))':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1))
'@typescript-eslint/types': 8.58.1
'@typescript-eslint/types': 8.63.0
eslint: 10.2.1(jiti@2.6.1)
eslint-visitor-keys: 4.2.1
espree: 10.4.0
@ -24718,8 +24784,8 @@ snapshots:
'@typescript-eslint/project-service@8.51.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.58.1(typescript@5.9.3)
'@typescript-eslint/types': 8.58.1
'@typescript-eslint/tsconfig-utils': 8.63.0(typescript@5.9.3)
'@typescript-eslint/types': 8.63.0
debug: 4.4.3(supports-color@10.2.2)
typescript: 5.9.3
transitivePeerDependencies:
@ -24727,7 +24793,7 @@ snapshots:
'@typescript-eslint/project-service@8.56.1(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.58.1(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.63.0(typescript@5.9.3)
'@typescript-eslint/types': 8.63.0
debug: 4.4.3(supports-color@10.2.2)
typescript: 5.9.3
@ -24736,8 +24802,8 @@ snapshots:
'@typescript-eslint/project-service@8.58.1(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.58.1(typescript@5.9.3)
'@typescript-eslint/types': 8.58.1
'@typescript-eslint/tsconfig-utils': 8.63.0(typescript@5.9.3)
'@typescript-eslint/types': 8.63.0
debug: 4.4.3(supports-color@10.2.2)
typescript: 5.9.3
transitivePeerDependencies:
@ -24999,7 +25065,7 @@ snapshots:
'@unocss/eslint-plugin@66.6.8(@typescript-eslint/types@8.63.0)(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.63.0
'@typescript-eslint/utils': 8.58.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/utils': 8.63.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
'@unocss/config': 66.6.8
'@unocss/core': 66.6.8
'@unocss/rule-utils': 66.6.8
@ -25248,6 +25314,12 @@ snapshots:
transitivePeerDependencies:
- typescript
'@vitejs/plugin-vue@6.0.6(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.13
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vue: 3.5.32(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.6(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.13
@ -25334,8 +25406,8 @@ snapshots:
'@vitest/eslint-plugin@1.6.15(@typescript-eslint/eslint-plugin@8.58.1(@typescript-eslint/parser@8.58.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.4)':
dependencies:
'@typescript-eslint/scope-manager': 8.58.1
'@typescript-eslint/utils': 8.58.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.63.0
'@typescript-eslint/utils': 8.63.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
eslint: 10.2.1(jiti@2.6.1)
optionalDependencies:
'@typescript-eslint/eslint-plugin': 8.58.1(@typescript-eslint/parser@8.51.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
@ -25525,6 +25597,15 @@ snapshots:
transitivePeerDependencies:
- vue
'@vue-macros/devtools@3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
sirv: 3.0.2
vue: 3.5.32(typescript@5.9.3)
optionalDependencies:
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- typescript
'@vue-macros/devtools@3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
sirv: 3.0.2
@ -25947,7 +26028,7 @@ snapshots:
dependencies:
'@huggingface/transformers': 3.8.1
'@moeru/eventa': 0.3.0(electron@41.2.1)(h3@2.0.1-rc.20(crossws@0.4.5(srvx@0.11.15)))
'@moeru/std': 0.1.0-beta.18
'@moeru/std': 0.1.0-beta.19
'@xsai-ext/shared-providers': 0.4.0-beta.12
'@xsai-transformers/shared': 0.1.0(electron@41.2.1)(h3@2.0.1-rc.20(crossws@0.4.5(srvx@0.11.15)))
'@xsai/embed': 0.4.4
@ -25961,7 +26042,7 @@ snapshots:
dependencies:
'@huggingface/transformers': 3.8.1
'@moeru/eventa': 0.3.0(electron@41.2.1)(h3@2.0.1-rc.20(crossws@0.4.5(srvx@0.11.15)))
'@moeru/std': 0.1.0-beta.18
'@moeru/std': 0.1.0-beta.19
'@xsai-ext/shared-providers': 0.4.0-beta.12
'@xsai/shared': 0.4.4
onnxruntime-common: 1.24.3
@ -25974,7 +26055,7 @@ snapshots:
dependencies:
'@huggingface/transformers': 3.8.1
'@moeru/eventa': 0.3.0(electron@41.2.1)(h3@2.0.1-rc.20(crossws@0.4.5(srvx@0.11.15)))
'@moeru/std': 0.1.0-beta.18
'@moeru/std': 0.1.0-beta.19
'@xsai-ext/shared-providers': 0.4.0-beta.12
'@xsai-transformers/shared': 0.1.0(electron@41.2.1)(h3@2.0.1-rc.20(crossws@0.4.5(srvx@0.11.15)))
'@xsai/embed': 0.4.4
@ -26613,7 +26694,7 @@ snapshots:
jsonc-parser: 3.3.1
package-manager-detector: 1.6.0
semver: 7.7.4
tinyexec: 1.1.1
tinyexec: 1.2.4
tinyglobby: 0.2.16
unconfig: 7.5.0
yaml: 2.8.3
@ -27602,7 +27683,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron-vite@5.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
electron-vite@5.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0)
@ -27610,7 +27691,7 @@ snapshots:
esbuild: 0.25.12
magic-string: 0.30.21
picocolors: 1.1.1
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
@ -27914,11 +27995,6 @@ snapshots:
'@eslint/compat': 2.0.5(eslint@10.2.1(jiti@2.6.1))
eslint: 10.2.1(jiti@2.6.1)
eslint-flat-config-utils@3.1.0:
dependencies:
'@eslint/config-helpers': 0.5.5
pathe: 2.0.3
eslint-flat-config-utils@3.2.0:
dependencies:
'@eslint/config-helpers': 0.5.5
@ -28034,15 +28110,6 @@ snapshots:
- supports-color
- typescript
eslint-plugin-perfectionist@5.8.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3):
dependencies:
'@typescript-eslint/utils': 8.58.1(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
eslint: 10.2.1(jiti@2.6.1)
natural-orderby: 5.0.0
transitivePeerDependencies:
- supports-color
- typescript
eslint-plugin-pnpm@1.6.0(eslint@10.2.1(jiti@2.6.1)):
dependencies:
empathic: 2.0.0
@ -31040,7 +31107,7 @@ snapshots:
dependencies:
citty: 0.2.1
pathe: 2.0.3
tinyexec: 1.1.1
tinyexec: 1.2.4
oauth-sign@0.9.0: {}
@ -31666,6 +31733,12 @@ snapshots:
preact: 10.28.1
web-vitals: 4.2.4
posthog-node@5.39.4(rxjs@7.8.2):
dependencies:
'@posthog/core': 1.39.6
optionalDependencies:
rxjs: 7.8.2
postject@1.0.0-alpha.6:
dependencies:
commander: 9.5.0
@ -33123,7 +33196,7 @@ snapshots:
pathe: 2.0.3
pnpm-workspace-yaml: 1.6.0
restore-cursor: 5.1.0
tinyexec: 1.1.1
tinyexec: 1.2.4
tinyglobby: 0.2.16
unconfig: 7.5.0
yaml: 2.8.3
@ -33245,8 +33318,6 @@ snapshots:
tinycolor2@1.6.0: {}
tinyexec@1.1.1: {}
tinyexec@1.2.4: {}
tinyglobby@0.2.16:
@ -33357,7 +33428,7 @@ snapshots:
rolldown: 1.0.0-rc.16
rolldown-plugin-dts: 0.23.2(oxc-resolver@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2))(rolldown@1.0.0-rc.16)(typescript@5.9.3)(vue-tsc@3.2.6(typescript@5.9.3))
semver: 7.7.4
tinyexec: 1.1.1
tinyexec: 1.2.4
tinyglobby: 0.2.16
tree-kill: 1.2.2
unconfig-core: 7.5.0
@ -33617,11 +33688,6 @@ snapshots:
'@unocss/preset-mini': 66.6.8
unocss: 66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
unocss-preset-scrollbar@4.0.0(unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))):
dependencies:
'@unocss/preset-mini': 66.6.8
unocss: 66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
unocss@66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@unocss/cli': 66.6.8
@ -33688,6 +33754,14 @@ snapshots:
unplugin: 2.3.11
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
unplugin-combine@2.3.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
esbuild: 0.27.2
rolldown: 1.0.0-rc.16
rollup: 4.60.1
unplugin: 2.3.11
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
unplugin-combine@2.3.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
esbuild: 0.27.2
@ -33709,6 +33783,19 @@ snapshots:
transitivePeerDependencies:
- supports-color
unplugin-info@1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
ci-info: 4.4.0
git-url-parse: 16.1.0
simple-git: 3.36.0
unplugin: 2.3.11
optionalDependencies:
esbuild: 0.27.2
rollup: 4.60.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
unplugin-info@1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
ci-info: 4.4.0
@ -33802,6 +33889,17 @@ snapshots:
rollup: 2.80.0
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
unplugin-yaml@4.1.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
unplugin: 3.0.0
yaml: 2.8.3
optionalDependencies:
esbuild: 0.27.2
rolldown: 1.0.0-rc.16
rollup: 4.60.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
unplugin@2.3.11:
dependencies:
'@jridgewell/remapping': 2.3.5
@ -34029,12 +34127,22 @@ snapshots:
- rollup
- supports-color
vite-dev-rpc@1.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
birpc: 2.9.0
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-hot-client: 2.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite-dev-rpc@1.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
birpc: 2.9.0
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-hot-client: 2.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite-hot-client@2.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-hot-client@2.1.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
@ -34080,6 +34188,21 @@ snapshots:
- tsx
- yaml
vite-plugin-inspect@11.3.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
ansis: 4.2.0
debug: 4.4.3(supports-color@10.2.2)
error-stack-parser-es: 1.0.5
ohash: 2.0.11
open: 10.2.0
perfect-debounce: 2.1.0
sirv: 3.0.2
unplugin-utils: 0.3.1
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-dev-rpc: 1.1.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
transitivePeerDependencies:
- supports-color
vite-plugin-inspect@11.3.3(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
ansis: 4.2.0
@ -34111,6 +34234,13 @@ snapshots:
- typescript
- ws
vite-plugin-mkcert@2.0.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
supports-color: 10.2.2
undici: 8.1.0
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-plugin-mkcert@2.0.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
@ -34129,6 +34259,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
vite-plugin-vue-devtools@8.1.1(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)):
dependencies:
'@vue/devtools-core': 8.1.1(vue@3.5.32(typescript@5.9.3))
'@vue/devtools-kit': 8.1.1
'@vue/devtools-shared': 8.1.1
sirv: 3.0.2
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vite-plugin-inspect: 11.3.3(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
vite-plugin-vue-inspector: 5.3.2(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
transitivePeerDependencies:
- '@nuxt/kit'
- supports-color
- vue
vite-plugin-vue-devtools@8.1.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.32(typescript@5.9.3)):
dependencies:
'@vue/devtools-core': 8.1.1(vue@3.5.32(typescript@5.9.3))
@ -34143,6 +34287,21 @@ snapshots:
- supports-color
- vue
vite-plugin-vue-inspector@5.3.2(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.29.0)
'@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0)
'@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0)
'@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.29.0)
'@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.29.0)
'@vue/compiler-dom': 3.5.32
kolorist: 1.8.0
magic-string: 0.30.21
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- supports-color
vite-plugin-vue-inspector@5.3.2(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
'@babel/core': 7.29.0
@ -34158,6 +34317,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
vite-plugin-vue-layouts@0.11.0(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
fast-glob: 3.3.3
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vue: 3.5.32(typescript@5.9.3)
vue-router: 5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
transitivePeerDependencies:
- supports-color
vite-plugin-vue-layouts@0.11.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-router@5.0.4(@pinia/colada@1.2.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(@vue/compiler-sfc@3.5.32)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3)):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
@ -34299,7 +34468,7 @@ snapshots:
picomatch: 4.0.4
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.1.1
tinyexec: 1.2.4
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
@ -34330,7 +34499,7 @@ snapshots:
picomatch: 4.0.4
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.1.1
tinyexec: 1.2.4
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
@ -34425,6 +34594,54 @@ snapshots:
- vue-tsc
- webpack
vue-macros@3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)):
dependencies:
'@vue-macros/better-define': 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vue@3.5.32(typescript@5.9.3))
'@vue-macros/boolean-prop': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/chain-call': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/common': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/config': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-emit': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-models': 3.1.2(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-prop': 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-props': 3.1.2(@vue-macros/reactivity-transform@3.1.2(vue@3.5.32(typescript@5.9.3)))(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-props-refs': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-render': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-slots': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/define-stylex': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/devtools': 3.1.2(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
'@vue-macros/export-expose': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/export-props': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/export-render': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/hoist-static': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/jsx-directive': 3.1.2(typescript@5.9.3)
'@vue-macros/named-template': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/reactivity-transform': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/script-lang': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/setup-block': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/setup-component': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/setup-sfc': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/short-bind': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/short-emits': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/short-vmodel': 3.1.2(vue@3.5.32(typescript@5.9.3))
'@vue-macros/volar': 3.1.2(typescript@5.9.3)(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3))
unplugin: 2.3.11
unplugin-combine: 2.3.0(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(unplugin@2.3.11)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
unplugin-vue-define-options: 3.1.2(vue@3.5.32(typescript@5.9.3))
vue: 3.5.32(typescript@5.9.3)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
- '@rspack/core'
- '@vueuse/core'
- esbuild
- rolldown
- rollup
- typescript
- vite
- vue-tsc
- webpack
vue-macros@3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@vueuse/core@14.2.1(vue@3.5.32(typescript@5.9.3)))(esbuild@0.27.2)(rolldown@1.0.0-rc.16)(rollup@4.60.1)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))(vue@3.5.32(typescript@5.9.3)):
dependencies:
'@vue-macros/better-define': 3.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vue@3.5.32(typescript@5.9.3))

View file

@ -20,6 +20,7 @@ overrides:
safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44
side-channel: npm:@nolyfill/side-channel@^1.0.44
string.prototype.matchall: npm:@nolyfill/string.prototype.matchall@^1.0.44
patchedDependencies:
'@mediapipe/tasks-vision': patches/@mediapipe__tasks-vision.patch
'@xsai/generate-text@0.5.0-beta.2': patches/@xsai__generate-text@0.5.0-beta.2.patch
@ -320,6 +321,7 @@ catalog:
postcss: ^8.5.10
postgres: ^3.4.9
posthog-js: 1.306.1
posthog-node: ^5.39.4
postprocessing: ^6.39.1
prismarine-block: ^1.23.0
prismarine-entity: ^2.6.0
@ -452,7 +454,6 @@ onlyBuiltDependencies:
- uiohook-napi
- utf-8-validate
- vue-demi
packageExtensions:
'@formkit/auto-animate':
peerDependencies:

View file

@ -22,4 +22,12 @@ export const POSTHOG_PROJECT_KEY
export const DEFAULT_POSTHOG_CONFIG = {
api_host: 'https://us.i.posthog.com',
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well
// Without this, posthog-js only fires `$pageview` on the initial page load.
// Every AIRI surface is an SPA (vue-router / VitePress client routing), so
// route changes would be invisible in PostHog. The '2025-05-24' defaults
// preset switches `capture_pageview` to 'history_change' — and because
// `capture_pageleave` defaults to 'if_capture_pageview', every surface
// that spreads this config also starts emitting `$pageleave`. That is
// intentional: pageleave is what makes route-level dwell time queryable.
defaults: '2025-05-24',
} as const satisfies Partial<PostHogConfig>