feat(stage-ui): let AIRI Cloud set the default of opt-in flags

A `cloud-opt-in` policy can now send `defaultEnabled`. It sets the state
of the flag before the user makes a choice. A saved choice still wins,
and "Use default" restores the Cloud default. A missing value means off,
so the behavior does not change until Cloud sends it.

Register the `official-responses` Cloud flag. It stays hidden and off
until Cloud grants it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
RainbowBird
2026-10-21 23:17:05 +08:00
co-authored by Claude Opus 5.5
parent 747d4f68aa
commit 5b423beb54
5 changed files with 68 additions and 6 deletions
@@ -2353,6 +2353,9 @@ pages:
description: Choose where the workspace opens.
single_window: 'Single window: the character grows into the workspace'
multi_window: 'Separate window: the character stays on the desktop'
official_responses:
title: Responses API for the official provider
description: Send chat requests to the official provider through the Responses API. If a model does not support it, chat uses Chat Completions for that model.
color-scheme:
description: Change the color scheme of the stage.
title: Color Scheme
@@ -2153,6 +2153,9 @@ pages:
description: 选择工作区开在哪里。
single_window: '单窗口:角色自己展开成工作区'
multi_window: '多窗口:角色留在桌面上'
official_responses:
title: 官方提供商使用 Responses API
description: 通过 Responses API 向官方提供商发送聊天请求。如果模型不支持,这个模型改用 Chat Completions。
color-scheme:
description: 更改舞台的配色方案
title: 配色方案
+3 -1
View File
@@ -7,7 +7,9 @@ Shared core for stage
Register flags in `libs/feature-flags.ts`. Read their state through `useFeatureFlagsStore().isEnabled(key)`.
Set `availability` to `local` or `cloud`. Local flags expose device switches without Cloud access.
Cloud decides whether each granted Cloud flag allows account opt-in or uses direct control. Client registrations do not duplicate this mode.
`cloud-opt-in` flags expose switches only after Cloud grants access to a verified account. They start disabled and retain choices per account on this device.
`cloud-opt-in` flags expose switches only after Cloud grants access to a verified account. They retain choices per account on this device.
A `cloud-opt-in` policy can send `defaultEnabled`. It sets the state before the user makes a choice. A missing value means off.
A saved choice always wins over the Cloud default. **Use default** removes the saved choice and restores the Cloud default.
`cloud-controlled` flags follow Cloud grants directly and never expose a switch.
Missing Cloud grants disable Cloud features. Refresh failures and account changes clear grants, not local choices.
`useCloudFetch()` in `composables/cloud.ts` shares the Cloud origin and authenticated fetch boundary. The store reads `GET /v1/feature-flags`.
@@ -3,7 +3,7 @@ import type { FeatureFlag } from './feature-flags'
import { parse } from 'valibot'
import { describe, expect, it } from 'vitest'
import { CHARACTER_CARD_SYNC_FLAG, featureFlagResponseSchema, featureFlags, FULLSCREEN_WORKSPACE_FLAG, offeredFeatureFlags, resolveFeatureFlag, resolveFeatureVariant } from './feature-flags'
import { CHARACTER_CARD_SYNC_FLAG, featureFlagResponseSchema, featureFlags, FULLSCREEN_WORKSPACE_FLAG, offeredFeatureFlags, OFFICIAL_RESPONSES_FLAG, resolveFeatureFlag, resolveFeatureVariant } from './feature-flags'
const feature: FeatureFlag = { key: 'test-feature', titleKey: 'test.title', descriptionKey: 'test.description', defaultEnabled: false, availability: 'local' }
@@ -31,11 +31,28 @@ describe('feature flag decisions', () => {
expect(resolveFeatureFlag(optIn, false, policy, true).enabled).toBe(false)
})
// Cloud can turn an opt-in flag on for accounts that made no choice. The user can still turn it off or reset it.
it('starts opt-in grants in the Cloud default state until the user chooses', () => {
const optIn: FeatureFlag = { ...feature, availability: 'cloud' }
const policy = { key: feature.key, mode: 'cloud-opt-in', source: 'global', defaultEnabled: true } as const
expect(resolveFeatureFlag(optIn, undefined, policy, true)).toEqual({ enabled: true, source: 'global', selectable: true })
expect(resolveFeatureFlag(optIn, false, policy, true)).toEqual({ enabled: false, source: 'local', selectable: true })
expect(resolveFeatureFlag(optIn, true, { ...policy, defaultEnabled: false }, true)).toEqual({ enabled: true, source: 'local', selectable: true })
expect(resolveFeatureFlag(optIn, undefined, { ...policy, defaultEnabled: false }, true)).toEqual({ enabled: false, source: 'global', selectable: true })
})
it('keeps the Cloud default off for signed-out users', () => {
const optIn: FeatureFlag = { ...feature, availability: 'cloud' }
const policy = { key: feature.key, mode: 'cloud-opt-in', source: 'global', defaultEnabled: true } as const
expect(resolveFeatureFlag(optIn, undefined, policy, false)).toEqual({ enabled: false, source: 'default', selectable: false })
})
it('applies cloud control without exposing a toggle or accepting device preferences', () => {
const controlled: FeatureFlag = { ...feature, availability: 'cloud' }
const policy = { key: feature.key, mode: 'cloud-controlled', source: 'global' } as const
expect(resolveFeatureFlag(controlled, false, policy, false)).toEqual({ enabled: true, source: 'global', selectable: false })
expect(resolveFeatureFlag(controlled, true, undefined, true).enabled).toBe(false)
expect(resolveFeatureFlag(controlled, undefined, { ...policy, defaultEnabled: false }, true)).toEqual({ enabled: true, source: 'global', selectable: false })
})
it('lets Cloud change modes without changing client registration', () => {
@@ -55,6 +72,20 @@ describe('feature flag decisions', () => {
expect(() => parse(featureFlagResponseSchema, { flags: [{ key: feature.key, mode: 'local', source: 'account' }] })).toThrow()
})
it('accepts policies with and without a Cloud default, and rejects a non-boolean default', () => {
expect(parse(featureFlagResponseSchema, { flags: [{ key: feature.key, mode: 'cloud-opt-in', source: 'global', defaultEnabled: true }] }).flags[0].defaultEnabled).toBe(true)
expect(parse(featureFlagResponseSchema, { flags: [{ key: feature.key, mode: 'cloud-opt-in', source: 'global' }] }).flags[0].defaultEnabled).toBeUndefined()
expect(() => parse(featureFlagResponseSchema, { flags: [{ key: feature.key, mode: 'cloud-opt-in', source: 'global', defaultEnabled: 'true' }] })).toThrow()
})
// The client registration never turns the Responses preference on. Only a Cloud grant shows the switch.
it('registers the official Responses preference as a Cloud flag that stays off without a grant', () => {
expect(featureFlags).toContain(OFFICIAL_RESPONSES_FLAG)
expect(OFFICIAL_RESPONSES_FLAG.availability).toBe('cloud')
expect(OFFICIAL_RESPONSES_FLAG.defaultEnabled).toBe(false)
expect(resolveFeatureFlag(OFFICIAL_RESPONSES_FLAG, undefined, undefined, true)).toEqual({ enabled: false, source: 'default', selectable: false })
})
it('registers character card sync as a local, off-by-default choice', () => {
expect(featureFlags).toContain(CHARACTER_CARD_SYNC_FLAG)
expect(CHARACTER_CARD_SYNC_FLAG.availability).toBe('local')
+27 -4
View File
@@ -1,11 +1,16 @@
import type { InferOutput } from 'valibot'
import { array, boolean, object, picklist, record, string } from 'valibot'
import { array, boolean, object, optional, picklist, record, string } from 'valibot'
export const featureFlagPolicySchema = object({
key: string(),
mode: picklist(['cloud-opt-in', 'cloud-controlled']),
source: picklist(['global', 'account']),
/**
* The state of a `cloud-opt-in` flag before the user makes a choice. A saved choice always wins. A missing value
* means off. `cloud-controlled` flags ignore it.
*/
defaultEnabled: optional(boolean()),
})
export const featureFlagResponseSchema = object({ flags: array(featureFlagPolicySchema) })
@@ -83,8 +88,20 @@ export const FULLSCREEN_WORKSPACE_FLAG = {
},
} as const satisfies FeatureFlag
/**
* Gates the Responses API preference of the official chat provider. The Cloud policy decides if an account gets the
* switch and its state before the user makes a choice. The registration stays off, so a missing grant keeps it off.
*/
export const OFFICIAL_RESPONSES_FLAG: FeatureFlag = {
key: 'official-responses',
titleKey: 'settings.pages.system.experimental.features.official_responses.title',
descriptionKey: 'settings.pages.system.experimental.features.official_responses.description',
defaultEnabled: false,
availability: 'cloud',
}
/** Only implemented experiments belong here. An empty catalog produces an empty settings page. */
export const featureFlags: readonly FeatureFlag[] = [CHARACTER_CARD_SYNC_FLAG, DISPLAY_MODEL_SYNC_FLAG, FULLSCREEN_WORKSPACE_FLAG]
export const featureFlags: readonly FeatureFlag[] = [CHARACTER_CARD_SYNC_FLAG, DISPLAY_MODEL_SYNC_FLAG, FULLSCREEN_WORKSPACE_FLAG, OFFICIAL_RESPONSES_FLAG]
/** The variant of a feature: the saved one while the feature still offers it, otherwise the first one. */
export function resolveFeatureVariant(feature: FeatureFlag | undefined, saved: string | undefined): string | undefined {
@@ -99,7 +116,10 @@ export function offeredFeatureFlags(desktop: boolean): readonly FeatureFlag[] {
return featureFlags.filter(feature => desktop || !feature.desktopOnly)
}
/** Missing cloud grants disable features. Only local and account-granted opt-in features expose settings. */
/**
* Missing cloud grants disable features. Only local and account-granted opt-in features expose settings.
* For a `cloud-opt-in` grant, the saved choice wins over the Cloud default, and the Cloud default wins over off.
*/
export function resolveFeatureFlag(feature: FeatureFlag, preference: boolean | undefined, policy: InferOutput<typeof featureFlagPolicySchema> | undefined, authenticated: boolean): FeatureFlagDecision {
if (feature.availability === 'local')
return { enabled: preference ?? feature.defaultEnabled, source: preference === undefined ? 'default' : 'local', selectable: true }
@@ -113,5 +133,8 @@ export function resolveFeatureFlag(feature: FeatureFlag, preference: boolean | u
if (!authenticated)
return { enabled: false, source: 'default', selectable: false }
return { enabled: preference ?? false, source: preference === undefined ? policy.source : 'local', selectable: true }
if (preference === undefined)
return { enabled: policy.defaultEnabled ?? false, source: policy.source, selectable: true }
return { enabled: preference, source: 'local', selectable: true }
}