diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets index 5455a46b6d..f0eb86e7a9 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets @@ -13,6 +13,10 @@ export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { const scenarioId = want.parameters?.['openbitfunDesignPreview']; + // The device-model scenarios are one family: the prefix opens the design + // gallery and the `-dark` suffix locks the colour mode, so the list below + // does not have to grow by one id per state of that feature. + const deviceModelScenario: boolean = String(scenarioId || '').startsWith('device-model'); if (scenarioId === 'history-scroll' || scenarioId === 'durable-timeline' || scenarioId === 'composer-submit' || scenarioId === 'catalog-refresh' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-dark' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-drawer-dark' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector' || scenarioId === 'device-selector-dark' || scenarioId === 'welcome-home' || scenarioId === 'connected-conversation' || scenarioId === 'streaming-dark' || scenarioId === 'reconnecting-wide' || scenarioId === 'narrow-multiline' || @@ -25,16 +29,19 @@ export default class EntryAbility extends UIAbility { scenarioId === 'copy-rollback-send-fail' || scenarioId === 'copy-rollback-fail' || scenarioId === 'copy-rollback-fail-dark' || scenarioId === 'copy-rollback-stale' || scenarioId === 'copy-rollback-busy' || scenarioId === 'copy-rollback-unsupported' || - scenarioId === 'interaction-mailbox' || scenarioId === 'interaction-mailbox-dark') { + scenarioId === 'interaction-mailbox' || scenarioId === 'interaction-mailbox-dark' || + deviceModelScenario) { this.initialPage = 'pages/preview/MobileDesignGallery'; this.previewStorage = new LocalStorage(); this.previewStorage.setOrCreate('scenarioId', scenarioId); AppStorage.setOrCreate('scenarioId', scenarioId); } try { - const colorMode = scenarioId === 'cold-start-home-dark' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector-dark' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-drawer-dark' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' || scenarioId === 'copy-selection-dark' || scenarioId === 'copy-rollback-dark' || scenarioId === 'copy-rollback-send-dark' || scenarioId === 'copy-rollback-fail-dark' + const colorMode = scenarioId === 'cold-start-home-dark' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector-dark' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-drawer-dark' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' || scenarioId === 'copy-selection-dark' || scenarioId === 'copy-rollback-dark' || scenarioId === 'copy-rollback-send-dark' || scenarioId === 'copy-rollback-fail-dark' || + (deviceModelScenario && String(scenarioId).endsWith('-dark')) ? ConfigurationConstant.ColorMode.COLOR_MODE_DARK - : scenarioId === 'cold-start-home' || scenarioId === 'catalog-refresh' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'file-visibility' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'device-selector' || scenarioId === 'connected-conversation' || scenarioId === 'reconnecting-wide' || scenarioId === 'copy-selection' || scenarioId === 'copy-selection-nav' || scenarioId === 'copy-selection-wide' || scenarioId === 'copy-rollback' || scenarioId === 'copy-rollback-send' || scenarioId === 'copy-rollback-send-fail' || scenarioId === 'copy-rollback-fail' || scenarioId === 'copy-rollback-stale' || scenarioId === 'copy-rollback-busy' || scenarioId === 'copy-rollback-unsupported' + : scenarioId === 'cold-start-home' || scenarioId === 'catalog-refresh' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'file-visibility' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'device-selector' || scenarioId === 'connected-conversation' || scenarioId === 'reconnecting-wide' || scenarioId === 'copy-selection' || scenarioId === 'copy-selection-nav' || scenarioId === 'copy-selection-wide' || scenarioId === 'copy-rollback' || scenarioId === 'copy-rollback-send' || scenarioId === 'copy-rollback-send-fail' || scenarioId === 'copy-rollback-fail' || scenarioId === 'copy-rollback-stale' || scenarioId === 'copy-rollback-busy' || scenarioId === 'copy-rollback-unsupported' || + (deviceModelScenario && !String(scenarioId).endsWith('-dark')) ? ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT : ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET; this.context.getApplicationContext().setColorMode(colorMode); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets index f7ccb74d3f..60afc5ec19 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets @@ -156,6 +156,12 @@ export interface RemoteCommand { tool_id?: string; updated_input?: Object; model_id?: string; + // set_model_defaults carries the whole object in one write, so all three + // managed slots are always present. A missing key means the same thing as an + // explicit null: no model configured (and, for `fast`, follow the primary). + primary?: string | null; + fast?: string | null; + image_understanding?: string | null; reason?: string; mode?: RemotePermissionMode; answers?: Object; @@ -403,6 +409,8 @@ export const REMOTE_CAPABILITY_HARNESS_PROFILES_V1: string = 'harness_profiles_v export const REMOTE_CAPABILITY_SESSION_ROLLBACK_V1: string = 'session_rollback_v1'; /** The host resolves `workspace_id` in commands and stamps it on its projections. */ export const REMOTE_CAPABILITY_WORKSPACE_ID_REFERENCES_V1: string = 'workspace_id_references_v1'; +/** The host reads and writes the device-level model role defaults. */ +export const REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1: string = 'device_model_defaults_v1'; export interface RemoteModelConfig { id: string; @@ -437,11 +445,21 @@ export interface RemoteReasoningCatalogProjection { presets?: RemoteReasoningPresetDescriptor[]; } +/** + * Device-level model role defaults, exactly as the host persists them. + * + * All six slots are always projected; only `primary`, `fast` and + * `image_understanding` are writable from the phone. `null` is a real value and + * means "no model configured" (`fast` then follows `primary`), so every reader + * must treat it as such rather than as an absent field. + */ export interface RemoteDefaultModels { - primary?: string; - fast?: string; - search?: string; - image_understanding?: string; + primary?: string | null; + fast?: string | null; + search?: string | null; + image_understanding?: string | null; + image_generation?: string | null; + speech_recognition?: string | null; } export interface RemoteModelCatalog { @@ -460,6 +478,18 @@ export interface SetSessionModelResponse extends CommandStatusResponse { model_id?: string; } +/** + * `set_model_defaults` acknowledgement. + * + * `default_models` is the reconcile result the host read back, all six slots + * included. A host that only acknowledges the command without the payload + * leaves it undefined, which the client answers with its own authoritative + * re-read instead of guessing. + */ +export interface ModelDefaultsUpdatedResponse extends CommandStatusResponse { + default_models?: RemoteDefaultModels; +} + export interface RemoteToolStatusResponse { permission_request_id?: string; id?: string; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets index 5d42e53bc9..4ce6ec83d3 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets @@ -1,5 +1,5 @@ import { WorkspaceToolsIntent } from '../state/WorkspaceToolsState'; -import { RemotePermissionMode, RemoteSession } from '../../model/RemoteModels'; +import { RemoteDefaultModels, RemoteModelCatalog, RemotePermissionMode, RemoteSession } from '../../model/RemoteModels'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { ConversationIntent } from './ConversationIntent'; import { AppRoute } from '../navigation/AppRouteContract'; @@ -87,6 +87,21 @@ export interface SettingsPresentationActions { readonly cloudSelectDevice: (device: CloudAccountDevice) => Promise; readonly getPermissionMode: () => Promise; readonly setPermissionMode: (mode: RemotePermissionMode) => Promise; + /** + * Device-level model catalog of the connected desktop: read without a session + * id, so the projection carries the defaults that belong to the desktop. + */ + readonly getDeviceModelCatalog: () => Promise; + /** + * Writes the three phone-managed slots as one object. `null` is a value: + * the fast model then follows the primary one and the others are left + * unconfigured. The answer is the desktop's reconciled projection. + */ + readonly setDeviceModelDefaults: ( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ) => Promise; readonly setLanguage: (language: string) => Promise; } @@ -131,6 +146,18 @@ export function emptyAppRootPresentationActions(): AppRootPresentationActions { cloudListDevices: async () => [], cloudSelectDevice: async () => {}, getPermissionMode: async () => 'ask', setPermissionMode: async (mode: RemotePermissionMode) => mode, + getDeviceModelCatalog: async () => ({ version: 0, models: [], default_models: {} }), + setDeviceModelDefaults: async ( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ) => { + return { + primary, + fast, + image_understanding: imageUnderstanding + }; + }, setLanguage: async () => {} }, onConnect: { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets index 192074fd82..e42de18746 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets @@ -2,7 +2,19 @@ import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/Mo import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { AccountDeviceSelectionPolicy } from '../policy/AccountDeviceSelectionPolicy'; -import { CARD, STATUS_SUCCESS, INK, LINE, MUTED, PAGE_BG, STATUS_DANGER, SOFT } from './Theme'; +import { DeviceModelSettingsEntryPolicy } from '../policy/DeviceModelSettingsEntryPolicy'; +import { + CARD, + CONTENT_ON_ACTION, + INK, + LINE, + MUTED, + PAGE_BG, + PRIMARY_ACTION, + SOFT, + STATUS_DANGER, + STATUS_SUCCESS +} from './Theme'; import { DefaultAccountAvatar } from './DefaultAccountAvatar'; /** Account identity and device management shared by Settings and account entry. */ @@ -16,12 +28,15 @@ export struct AccountProfilePanel { @Param controlTargetType: string = 'none'; @Param controlTargetDeviceId: string = ''; @Param connectionState: string = 'idle'; + /** The connected desktop advertises `device_model_defaults_v1`. */ + @Param supportsDeviceModelDefaults: boolean = false; @BuilderParam preferences: () => void = this.EmptyBuilder; @BuilderParam connection: () => void = this.EmptyBuilder; @BuilderParam about: () => void = this.EmptyBuilder; @Event onLoggedOut: () => void = () => {}; @Event onOpenAccount: () => void = () => {}; @Event onReconnect: () => void = () => {}; + @Event onOpenDeviceModelSettings: () => void = () => {}; @Event cloudLogout: () => Promise = async (): Promise => {}; @Event cloudListDevices: () => Promise = async (): Promise => []; @@ -32,6 +47,14 @@ export struct AccountProfilePanel { @Local accountDevicesError: string = ''; @Local switchingDeviceId: string = ''; @Local logoutBusy: boolean = false; + /** + * Why the device model settings entry could not open, shown once under the + * row that was tapped. A missing capability is a state, not a failure, so it + * is explained in place instead of by opening a page that cannot answer. + */ + @Local deviceModelSettingsReason: string = ''; + /** The row the reason belongs to; empty when no row was tapped. */ + @Local deviceModelSettingsReasonDeviceId: string = ''; aboutToAppear(): void { if (this.isAccountAuthenticated()) { @@ -157,9 +180,18 @@ export struct AccountProfilePanel { .fontSize(MobileDesignTypography.bodySmall.size).lineHeight(MobileDesignTypography.bodySmall.lineHeight).fontColor(MUTED).width('100%') } else { ForEach(this.desktopDevices(), (device: CloudAccountDevice) => { - this.AccountDeviceRow(device) + Column({ space: 0 }) { + this.AccountDeviceRow(device) + if (this.deviceModelSettingsReasonDeviceId === device.deviceId && + this.deviceModelSettingsReason.length > 0) { + this.DeviceModelSettingsReasonBand() + } + } + .width('100%') }, (device: CloudAccountDevice): string => - `${device.deviceId}:${device.online ? 'online' : 'offline'}:${device.lastSeenAt || 0}:${device.deviceName}`) + `${device.deviceId}:${device.online ? 'online' : 'offline'}:${device.lastSeenAt || 0}:${device.deviceName}` + + `:${this.isCurrentControlDevice(device) ? 'driven' : 'idle'}` + + `:${this.supportsDeviceModelDefaults ? 'model-defaults' : 'no-model-defaults'}`) } if (this.accountDevicesError.length > 0 && this.desktopDevices().length > 0) { Text(this.accountDevicesError) @@ -195,7 +227,9 @@ export struct AccountProfilePanel { } .layoutWeight(1) .alignItems(HorizontalAlign.Start) - if (this.shouldShowDeviceConnectAction(device)) { + if (this.showsDeviceModelSettingsEntry(device)) { + this.DeviceModelSettingsEntry(device) + } else if (this.shouldShowDeviceConnectAction(device)) { Text(this.switchingDeviceId === device.deviceId ? RemoteI18n.t('remote.settings.deviceConnecting') : RemoteI18n.t('connect.connect')) .fontSize(MobileDesignTypography.bodyMedium.size).fontColor(INK) @@ -215,6 +249,70 @@ export struct AccountProfilePanel { }) } + /** + * The model settings entry on the row of the desktop this phone is driving. + * + * Same pill as the row's connect action, because it is the same slot: the + * connected target offers its own settings instead of re-connecting. An older + * desktop keeps the pill, greyed, with an upgrade corner badge, and tapping it + * explains itself instead of opening a page that cannot answer. + */ + @Builder + private DeviceModelSettingsEntry(device: CloudAccountDevice) { + // The Stack wraps the pill, so the badge can be lifted above the pill's top + // corner without taking part in the row's layout: an inline badge would push + // the device name out of the row's width. + Stack({ alignContent: Alignment.TopEnd }) { + Row({ space: 4 }) { + SymbolGlyph($r('sys.symbol.gearshape')) + .fontSize(14) + .fontColor([MUTED]) + Text(RemoteI18n.t('settings.model.settingsTitle')) + .fontSize(MobileDesignTypography.bodyMedium.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .padding({ left: 10, right: 10, top: 6, bottom: 6 }) + .backgroundColor(SOFT) + .borderRadius(14) + .opacity(this.deviceModelSettingsEnabled() ? 1 : 0.38) + // The two states of this pill are its whole contract, so the state is part + // of its identity: an enabled entry that still looked greyed would be the + // same bug as a greyed one that opened the page. + .id(`device-model-entry-${this.deviceModelSettingsEnabled() ? 'enabled' : 'degraded'}`) + .onClick(() => { + this.openDeviceModelSettings(device); + }) + + if (!this.deviceModelSettingsEnabled()) { + Text(RemoteI18n.t('settings.model.upgradeBadge')) + .fontSize(MobileDesignTypography.labelSmall.size) + .fontWeight(FontWeight.Bold) + .fontColor(CONTENT_ON_ACTION) + .height(15) + .padding({ left: 4, right: 4 }) + .backgroundColor(PRIMARY_ACTION) + .borderRadius(4) + .offset({ x: 0, y: -14 }) + } + } + } + + @Builder + private DeviceModelSettingsReasonBand() { + Text(this.deviceModelSettingsReason) + .width('100%') + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(MUTED) + .padding({ left: 12, right: 12, top: 10, bottom: 10 }) + .backgroundColor(SOFT) + .borderRadius(MobileDesignGeometry.settingsCompactCardRadius) + .margin({ left: 4, right: 4, bottom: 4 }) + } + @Builder private LogoutAction() { Divider() @@ -286,6 +384,36 @@ export struct AccountProfilePanel { ); } + /** The entry belongs to the desktop this phone is connected to and driving. */ + private showsDeviceModelSettingsEntry(device: CloudAccountDevice): boolean { + return DeviceModelSettingsEntryPolicy.showsEntry( + device.online, + device.deviceId, + this.deviceId, + this.controlTargetDeviceId, + this.connectionState + ); + } + + private deviceModelSettingsEnabled(): boolean { + return DeviceModelSettingsEntryPolicy.entryEnabled(this.supportsDeviceModelDefaults); + } + + private openDeviceModelSettings(device: CloudAccountDevice): void { + const reasonKey = DeviceModelSettingsEntryPolicy.degradeReasonKey( + this.showsDeviceModelSettingsEntry(device), + this.supportsDeviceModelDefaults + ); + if (reasonKey.length > 0) { + this.deviceModelSettingsReasonDeviceId = device.deviceId; + this.deviceModelSettingsReason = RemoteI18n.t(reasonKey); + return; + } + this.deviceModelSettingsReasonDeviceId = ''; + this.deviceModelSettingsReason = ''; + this.onOpenDeviceModelSettings(); + } + private async connectAccountDevice(device: CloudAccountDevice): Promise { if (!this.canActivateAccountDevice(device)) return; this.switchingDeviceId = device.deviceId; @@ -364,4 +492,14 @@ export struct AccountProfilePanel { this.refreshAccountDevices(); } } + + /** + * A link or capability change makes the explanation stale: it described the + * host as it was when the entry was tapped. + */ + @Monitor('connectionState', 'supportsDeviceModelDefaults') + private clearDeviceModelSettingsReason(): void { + this.deviceModelSettingsReasonDeviceId = ''; + this.deviceModelSettingsReason = ''; + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets index b571d00dc3..b65596fece 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets @@ -13,7 +13,7 @@ import { SettingsPagePolicy } from '../policy/SettingsPagePolicy'; import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { DeviceDirectoryState } from '../state/DeviceDirectoryState'; import { RemotePageState } from '../state/RemotePageState'; -import { REMOTE_CAPABILITY_HARNESS_PROFILES_V1 } from '../../model/RemoteModels'; +import { REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1, REMOTE_CAPABILITY_HARNESS_PROFILES_V1 } from '../../model/RemoteModels'; import { AppSidebar } from './AppSidebar'; import { ConnectView } from './ConnectView'; import { AccountLoginSheet } from './AccountLoginSheet'; @@ -156,6 +156,8 @@ export struct AppSettingsSurface { controlTargetType: this.remotePageState.controlTargetType, controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, connectionState: this.remotePageState.connectionState, + supportsDeviceModelDefaults: this.remotePageState.supportsHostCapability( + REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1), statusText: this.remotePageState.conversation.statusText, onOpenAccount: this.actions.onSettings.openAccount, onDisconnect: this.actions.onSettings.disconnect, @@ -166,6 +168,8 @@ export struct AppSettingsSurface { cloudSelectDevice: this.actions.onSettings.cloudSelectDevice, getPermissionMode: this.actions.onSettings.getPermissionMode, setPermissionMode: this.actions.onSettings.setPermissionMode, + getDeviceModelCatalog: this.actions.onSettings.getDeviceModelCatalog, + setDeviceModelDefaults: this.actions.onSettings.setDeviceModelDefaults, onSetLanguage: this.actions.onSettings.setLanguage, onClose: this.actions.onSettings.close }) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DeviceModelDefaultsPanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DeviceModelDefaultsPanel.ets new file mode 100644 index 0000000000..802934e527 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DeviceModelDefaultsPanel.ets @@ -0,0 +1,514 @@ +import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { + RemoteModelDefaultsRole, + RemoteModelDefaultsState +} from '../../services/RemoteModelDefaultsController'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; +import { ConversationModelPresentationPolicy } from '../policy/ConversationModelPresentationPolicy'; +import { ModelRoleCandidatesPolicy, ModelRoleRowProjection } from '../policy/ModelRoleCandidatesPolicy'; +import { toConversationUiModel } from '../state/ConversationUiModels'; +import { DeviceModelDefaultsSheetState } from '../state/DeviceModelDefaultsSheetState'; +import { SettingsSheetState } from '../state/SettingsSheetState'; +import { ModelUnavailableCard } from './ModelUnavailableCard'; +import { NavigationBackButton } from './NavigationBackButton'; +import { SheetCloseButton } from './SheetCloseButton'; +import { SHEET_HEADER_HEIGHT, SHEET_HEADER_SIDE_PADDING, SHEET_HORIZONTAL_PADDING } from './SheetLayout'; +import { + CARD, + INK, + LINE, + MUTED, + PAGE_BG, + SOFT, + STATUS_DANGER, + STATUS_SUCCESS, + SUBTLE, + TRANSPARENT +} from './Theme'; + +/** The three roles in the order the page lists them. */ +const ROLE_ORDER: RemoteModelDefaultsRole[] = ['primary', 'fast', 'image_understanding']; + +/** + * The device model settings page: the three role slots of the desktop this phone + * is driving, on the same sheet, one level above the settings list. + * + * It is a page of the sheet rather than a stand-alone service panel: the header + * carries back and close, the rows are the settings list's row language, and the + * write feedback happens in place. The desktop owns the values, so a row shows + * the last device read and never the tap that is still in flight: selecting a + * model returns here and the value changes only when the read-back lands. + * + * Control flow stays in the sheet's state object the whole time — an older + * desktop that cannot answer, a desktop that stopped answering, and a phone that + * is simply not connected are three different states, and all three are stated + * in the page instead of failing the tap silently. + */ +@ComponentV2 +export struct DeviceModelDefaultsPanel { + @Param sheetState: SettingsSheetState = new SettingsSheetState(); + @Param deviceModels: DeviceModelDefaultsSheetState = new DeviceModelDefaultsSheetState(); + @Param desktopName: string = ''; + @Param desktopId: string = ''; + @Param controlTargetType: string = 'none'; + @Param connectionState: string = 'idle'; + @Event onClose: () => void = () => {}; + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); + + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + // The page is where the device is actually read: opening the sheet only arms + // the gate, so a user who never comes here never spends the read. + void this.deviceModels.ensureLoaded(); + } + + aboutToDisappear(): void { + this.insets.unbind(); + } + + build() { + Column({ space: 0 }) { + this.PageHeader() + Scroll() { + Column({ space: 0 }) { + this.DeviceSummaryCard() + if (this.deviceModels.state.available) { + this.RoleSection() + } else { + this.UnavailableCard() + } + this.ScopeCard() + } + .width('100%') + .padding({ + left: SHEET_HORIZONTAL_PADDING, + right: SHEET_HORIZONTAL_PADDING, + top: 22, + bottom: this.insets.tailSpacing(34) + }) + } + .width('100%') + .layoutWeight(1) + .scrollBar(BarState.Off) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } + + /** + * The page header, in the one header language the sheet has: a quiet control on + * each side and the page's identity between them. Back is a real second level — + * this page was pushed from the settings list — so it sits next to the close + * that leaves the sheet entirely. + */ + @Builder + private PageHeader() { + Row() { + NavigationBackButton({ + isEnabled: !this.locked(), + onBack: (): void => { + this.sheetState.setPage('main'); + } + }) + Column({ space: 3 }) { + Text(RemoteI18n.t('settings.model.settingsTitle')) + .width('100%') + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.deviceLabel()) + .width('100%') + .fontSize(MobileDesignTypography.headlineSmall.size) + .lineHeight(MobileDesignTypography.headlineSmall.lineHeight) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + SheetCloseButton({ + isEnabled: !this.locked(), + onClose: this.onClose + }) + } + .width('100%') + .height(SHEET_HEADER_HEIGHT) + .padding({ left: SHEET_HEADER_SIDE_PADDING, right: SHEET_HEADER_SIDE_PADDING }) + .alignItems(VerticalAlign.Center) + .border({ width: { bottom: 1 }, color: LINE }) + } + + /** + * Which desktop these values belong to. This page edits a device, not a + * session, so the device is stated once at the top rather than repeated on + * every row. + */ + @Builder + private DeviceSummaryCard() { + Row({ space: 12 }) { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(20) + .fontColor([INK]) + .opacity(0.9) + } + .width(28) + .height(28) + Column({ space: 2 }) { + Text(this.deviceLabel()) + .width('100%') + .fontSize(MobileDesignTypography.titleSmall.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.summaryStatus()) + .width('100%') + .fontSize(MobileDesignTypography.bodySmall.size) + .fontColor(this.connectionState === 'connected' ? STATUS_SUCCESS : MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .constraintSize({ minHeight: MobileDesignGeometry.modelSourceRowHeight }) + .padding({ left: 18, right: 18, top: 10, bottom: 10 }) + .alignItems(VerticalAlign.Center) + .backgroundColor(CARD) + .borderRadius(MobileDesignGeometry.settingsCardRadius) + .margin({ bottom: 24 }) + } + + @Builder + private RoleSection() { + Column({ space: 0 }) { + if (!this.deviceModels.state.loaded) { + this.ReadingCard() + } else { + Text(RemoteI18n.t('settings.model.section')) + .width('100%') + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + .margin({ left: 8, bottom: 10 }) + Column() { + ForEach(ROLE_ORDER, (role: RemoteModelDefaultsRole) => { + this.RoleRow(role) + }, (role: RemoteModelDefaultsRole): string => role) + } + .width('100%') + .backgroundColor(CARD) + .borderRadius(MobileDesignGeometry.settingsCardRadius) + if (this.deviceModels.state.error.length > 0 || this.deviceModels.feedbackVisible) { + this.WriteFeedback() + } + if (ModelRoleCandidatesPolicy.isEmptyCatalog(this.deviceModels.state.catalog)) { + this.EmptyCatalogCard() + } + } + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + + @Builder + private ReadingCard() { + Text(this.readingText()) + .width('100%') + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(MUTED) + .padding({ left: 18, right: 18, top: 16, bottom: 16 }) + .backgroundColor(CARD) + .borderRadius(MobileDesignGeometry.settingsCardRadius) + } + + /** + * One role slot. The row is the settings list's own row language — a leading + * mark, a two-line body and a trailing control — because these three rows sit + * on the same sheet as the permission rows above them. + * + * The mark states whether the role resolves to a model at all, the meta line + * states which one, and the row is the way into that role's picker. Nothing is + * written from here: the picker is the only place a model is chosen. + */ + @Builder + private RoleRow(role: RemoteModelDefaultsRole) { + Row({ space: 12 }) { + Stack({ alignContent: Alignment.Center }) { + if (this.roleBusy(role)) { + LoadingProgress() + .width(16) + .height(16) + .color(INK) + } else if (this.roleProjection(role).resolved) { + SymbolGlyph($r('sys.symbol.checkmark_circle_fill')) + .fontSize(19) + .fontColor([INK]) + .width(20) + .height(20) + } + } + .width(22) + .height(24) + Column({ space: 3 }) { + Text(RemoteI18n.t(ModelRoleCandidatesPolicy.roleLabelKey(role))) + .width('100%') + .fontSize(MobileDesignTypography.bodyLarge.size) + .lineHeight(MobileDesignTypography.bodyLarge.lineHeight) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.roleMeta(role)) + .width('100%') + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontColor(MUTED) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + Row({ space: 4 }) { + if (this.roleUpdated(role)) { + Text(RemoteI18n.t('status.modelDefaultsUpdated')) + .fontSize(MobileDesignTypography.bodyMedium.size) + .fontWeight(FontWeight.Medium) + .fontColor(STATUS_SUCCESS) + .maxLines(1) + } + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(14) + .fontColor([MUTED]) + } + .alignItems(VerticalAlign.Center) + } + .width('100%') + .height(72) + .padding({ left: 18, right: 18 }) + .alignItems(VerticalAlign.Center) + .backgroundColor(this.roleBusy(role) ? SOFT : TRANSPARENT) + .opacity(this.locked() ? 0.6 : 1) + .id(`device-model-role-${role}`) + .enabled(!this.locked()) + .onClick(() => { + this.sheetState.openModelPick(role); + }) + } + + /** + * What the write did, in the two shapes it has: the host's own rejection with a + * way to answer it again, or a confirmed write whose value has already been + * read back. Neither is a place to show the value that was asked for — that is + * the point of reading the desktop back. + */ + @Builder + private WriteFeedback() { + Row({ space: 10 }) { + if (this.deviceModels.state.error.length > 0) { + Text(this.deviceModels.state.error) + .layoutWeight(1) + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(STATUS_DANGER) + .maxLines(3) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(RemoteI18n.t('common.retry')) + .fontSize(MobileDesignTypography.bodyMedium.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .padding({ left: 10, right: 10, top: 6, bottom: 6 }) + .backgroundColor(SOFT) + .borderRadius(14) + .id('device-model-retry') + .enabled(!this.locked()) + .onClick(() => { + void this.deviceModels.retry(); + }) + } else if (this.deviceModels.feedbackVisible) { + Text(RemoteI18n.f('settings.model.updatedResult', this.writtenValue())) + .layoutWeight(1) + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(STATUS_SUCCESS) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .id('device-model-updated') + } + } + .width('100%') + .padding({ left: 8, right: 8, top: 10 }) + .alignItems(VerticalAlign.Center) + } + + /** + * The desktop has no models at all. The rows stay where they are — the user + * came here for three slots and three slots are what the device has — and this + * card says what to do about it, because nothing on this page can add a model. + */ + @Builder + private EmptyCatalogCard() { + Column({ space: 6 }) { + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(26) + .fontColor([SUBTLE]) + Text(RemoteI18n.t('settings.model.emptyTitle')) + .fontSize(MobileDesignTypography.bodyLarge.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .textAlign(TextAlign.Center) + Text(RemoteI18n.t('settings.model.emptyText')) + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + } + .width('100%') + .padding({ left: 18, right: 18, top: 26, bottom: 22 }) + .alignItems(HorizontalAlign.Center) + .margin({ top: 24 }) + .backgroundColor(CARD) + .borderRadius(MobileDesignGeometry.settingsCardRadius) + } + + /** + * The page's one scope card, in every state it has: an older desktop is told + * why in place of the rows, and the rules that hold regardless are still true + * once it is upgraded. + */ + @Builder + private ScopeCard() { + Column({ space: 4 }) { + this.ScopeLine(RemoteI18n.t('settings.model.scopeNewSessions')) + this.ScopeLine(RemoteI18n.t('settings.model.scopeFastFollowsPrimary')) + this.ScopeLine(RemoteI18n.t('settings.model.scopeImageUnderstanding')) + } + .width('100%') + .padding({ left: 18, right: 18, top: 14, bottom: 14 }) + .alignItems(HorizontalAlign.Start) + .margin({ top: 24 }) + .backgroundColor(CARD) + .borderRadius(MobileDesignGeometry.settingsCardRadius) + } + + @Builder + private ScopeLine(text: string) { + Text(text) + .width('100%') + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(MUTED) + } + + /** + * The desktop cannot answer this page. The entry that led here is greyed for + * the same reason, but the page is reachable from a stale capability set too, + * so it states the reason itself — the controller already decided which of the + * two it is, and this card does not re-judge it. + * + * The card is shared with the picker, which reaches it in one more state: a + * read that failed while the device could still answer. This page shows that + * case above its own rows instead, so the retry here never has work to do. + */ + @Builder + private UnavailableCard() { + ModelUnavailableCard({ + reason: this.deviceModels.state.error.length > 0 ? this.deviceModels.state.error : + RemoteI18n.t('status.remoteUnavailable'), + canRetry: ModelRoleCandidatesPolicy.canRepeatRead(this.deviceModelState()), + busy: this.deviceModels.state.loading || this.locked(), + cardId: 'device-model-unavailable', + onRetry: (): void => { + void this.deviceModels.retry(); + } + }) + } + + private deviceLabel(): string { + return this.desktopName.length > 0 ? this.desktopName : this.desktopId; + } + + /** The desktop this page belongs to, and how the phone reached it. */ + private summaryStatus(): string { + const presence = this.connectionState === 'connected' ? + RemoteI18n.t('connect.connected') : RemoteI18n.t('connect.disconnected'); + const source = this.controlTargetType === 'account_device' ? + RemoteI18n.t('settings.modelService.accountSource') : + RemoteI18n.t('settings.modelService.localSource'); + return `${presence} · ${source}`; + } + + private readingText(): string { + const status = this.deviceModels.state.statusText; + return status.length > 0 ? status : RemoteI18n.t('status.loadingModelDefaults'); + } + + /** A write in flight owns the page: both header controls and every row wait for it. */ + private locked(): boolean { + return this.deviceModels.state.busy; + } + + private deviceModelState(): RemoteModelDefaultsState { + return this.deviceModels.state; + } + + private roleProjection(role: RemoteModelDefaultsRole): ModelRoleRowProjection { + return ModelRoleCandidatesPolicy.roleRow( + this.deviceModelState().roles, role, this.deviceModelState().catalog); + } + + private roleBusy(role: RemoteModelDefaultsRole): boolean { + return this.locked() && this.deviceModels.pendingRole === role; + } + + /** True while the just-written role row carries the transient result word. */ + private roleUpdated(role: RemoteModelDefaultsRole): boolean { + return this.deviceModels.feedbackVisible && this.deviceModels.pendingRole === role; + } + + /** + * The role's value line: the model the desktop returned, or the placeholder + * for a role that has none, with its fallback hint. A model the desktop holds + * but no longer lists is shown by id — the value is real even when the catalog + * cannot describe it. + */ + private roleMeta(role: RemoteModelDefaultsRole): string { + const base = this.roleValue(role); + const projection = this.roleProjection(role); + if (projection.hintKey.length > 0) { + return `${base} · ${RemoteI18n.t(projection.hintKey)}`; + } + return base; + } + + private roleValue(role: RemoteModelDefaultsRole): string { + const projection = this.roleProjection(role); + if (projection.model) { + return ConversationModelPresentationPolicy.roleMetaLabel( + toConversationUiModel(projection.model), projection.modelId); + } + if (projection.modelId.length > 0) { + return projection.modelId; + } + return RemoteI18n.t(projection.unsetKey); + } + + /** The value the confirmed write left behind, read back from the device. */ + private writtenValue(): string { + const role = this.deviceModels.pendingRole; + return role === '' ? RemoteI18n.t('status.modelDefaultsUpdated') : this.roleValue(role); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelRolePickerPanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelRolePickerPanel.ets new file mode 100644 index 0000000000..273734ed27 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelRolePickerPanel.ets @@ -0,0 +1,421 @@ +import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { RemoteModelConfig } from '../../model/RemoteModels'; +import { RemoteModelDefaultsRole } from '../../services/RemoteModelDefaultsController'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; +import { ConversationModelPresentationPolicy } from '../policy/ConversationModelPresentationPolicy'; +import { ModelPickerView, ModelRoleCandidatesPolicy } from '../policy/ModelRoleCandidatesPolicy'; +import { toConversationUiModel } from '../state/ConversationUiModels'; +import { DeviceModelDefaultsSheetState } from '../state/DeviceModelDefaultsSheetState'; +import { SettingsSheetState } from '../state/SettingsSheetState'; +import { ModelUnavailableCard } from './ModelUnavailableCard'; +import { NavigationBackButton } from './NavigationBackButton'; +import { SheetCloseButton } from './SheetCloseButton'; +import { SHEET_HEADER_HEIGHT, SHEET_HEADER_SIDE_PADDING, SHEET_HORIZONTAL_PADDING } from './SheetLayout'; +import { CARD, INK, LINE, MUTED, PAGE_BG, SOFT, TRANSPARENT } from './Theme'; + +/** + * The role picker: the second level of the sheet's model settings stack, for one + * role at a time. + * + * Candidates are the desktop's own catalog, filtered to what the role can + * actually run and kept in the order the desktop reports, so the list here and + * the list on the desktop describe the same device. The pinned row is the "no + * model" value, which is a value: for `fast` it means "follow the primary" and + * for the multimodal slot it means the capability is off. The primary model has + * no such row — it is the one slot the desktop must always be able to resolve. + * + * Selecting does not draw the selection: it asks the controller to write and + * leaves for the settings page, which then shows the value the desktop read + * back. A picker that checked the row the user tapped would be promising a + * configuration that may have been repaired while it was stored. + */ +@ComponentV2 +export struct ModelRolePickerPanel { + @Param sheetState: SettingsSheetState = new SettingsSheetState(); + @Param deviceModels: DeviceModelDefaultsSheetState = new DeviceModelDefaultsSheetState(); + @Param desktopName: string = ''; + @Param desktopId: string = ''; + @Event onClose: () => void = () => {}; + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); + + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + // The picker never opens before the page that pushes it, but it can be the + // first thing a restored sheet shows: reading here keeps that state answered. + void this.deviceModels.ensureLoaded(); + } + + aboutToDisappear(): void { + this.insets.unbind(); + } + + build() { + Column({ space: 0 }) { + this.PageHeader() + Scroll() { + Column({ space: 0 }) { + // The card comes first: it is why the list below is what it is, and on a + // device that cannot answer there is no list at all. + if (this.view().reason) { + this.ReasonCard() + } + if (this.view().reading) { + this.ReadingCard() + } + if (this.view().list) { + this.FilterLine() + if (this.allowsUnset()) { + this.UnsetRow() + } + ForEach(this.candidates(), (model: RemoteModelConfig) => { + this.CandidateRow(model) + }, (model: RemoteModelConfig): string => model.id) + // Only a list the device itself answered may be explained away: after a + // failed read it is short for a reason the card above already states. + if (this.view().answered && this.candidates().length === 0) { + this.NoCandidatesLine() + } + } + } + .width('100%') + .padding({ + left: SHEET_HORIZONTAL_PADDING, + right: SHEET_HORIZONTAL_PADDING, + top: 12, + bottom: this.insets.tailSpacing(34) + }) + } + .width('100%') + .layoutWeight(1) + .scrollBar(BarState.Off) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } + + /** Same header language as the page below it: back one level, close the sheet. */ + @Builder + private PageHeader() { + Row() { + NavigationBackButton({ + isEnabled: !this.locked(), + onBack: (): void => { + this.sheetState.backFromModelPick(); + } + }) + Column({ space: 3 }) { + Text(RemoteI18n.t('settings.model.settingsTitle')) + .width('100%') + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.roleLabel()) + .width('100%') + .fontSize(MobileDesignTypography.headlineSmall.size) + .lineHeight(MobileDesignTypography.headlineSmall.lineHeight) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + SheetCloseButton({ + isEnabled: !this.locked(), + onClose: this.onClose + }) + } + .width('100%') + .height(SHEET_HEADER_HEIGHT) + .padding({ left: SHEET_HEADER_SIDE_PADDING, right: SHEET_HEADER_SIDE_PADDING }) + .alignItems(VerticalAlign.Center) + .border({ width: { bottom: 1 }, color: LINE }) + } + + /** + * Why the list is as short as it is. Filtering silently is what makes a user + * think the device lost its models, so the rule is stated above the rows + * together with the device it applies to. + */ + @Builder + private FilterLine() { + Text(RemoteI18n.f(ModelRoleCandidatesPolicy.filterKey(this.role()), this.deviceLabel())) + .width('100%') + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontColor(MUTED) + .padding({ left: 4, right: 4 }) + .margin({ bottom: 12 }) + } + + /** + * The "no model" value of this role, pinned above the candidates and separated + * from them, because it is not one of them: it is the state of having none. + */ + @Builder + private UnsetRow() { + Column({ space: 0 }) { + Row({ space: 10 }) { + this.SelectionMark(this.pinSelected()) + Column({ space: 2 }) { + Text(RemoteI18n.t(ModelRoleCandidatesPolicy.unsetLabelKey(this.role()))) + .width('100%') + .fontSize(MobileDesignTypography.titleSmall.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(RemoteI18n.t(ModelRoleCandidatesPolicy.unsetHintKey(this.role()))) + .width('100%') + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontColor(MUTED) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .constraintSize({ minHeight: MobileDesignGeometry.modelAccountRowHeight }) + .padding({ left: 10, right: 10, top: 8, bottom: 8 }) + .alignItems(VerticalAlign.Center) + .backgroundColor(this.pinSelected() ? SOFT : TRANSPARENT) + .borderRadius(MobileDesignGeometry.composerModelSelectorRowRadius) + .id('device-model-pick-unset') + .enabled(!this.locked()) + .opacity(this.locked() ? 0.6 : 1) + .onClick(() => { + this.select(null); + }) + // The pinned value is a different kind of answer from a model, so the list + // says so with a rule instead of letting it read as the first candidate. + if (this.candidates().length > 0) { + Divider() + .strokeWidth(1) + .color(LINE) + .margin({ top: 6, bottom: 6 }) + } + } + .width('100%') + } + + /** One candidate model: what it is called, what it runs on, and its capability. */ + @Builder + private CandidateRow(model: RemoteModelConfig) { + Row({ space: 10 }) { + this.SelectionMark(this.candidateSelected(model.id)) + Column({ space: 2 }) { + Row({ space: 6 }) { + Text(this.modelLabel(model)) + .layoutWeight(1) + .fontSize(MobileDesignTypography.titleSmall.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + if (this.showsCapabilityBadge()) { + Text(RemoteI18n.t('settings.model.capabilityImageUnderstanding')) + .fontSize(MobileDesignTypography.labelSmall.size) + .fontColor(MUTED) + .padding({ left: 6, right: 6, top: 1, bottom: 1 }) + .backgroundColor(SOFT) + .borderRadius(4) + } + } + .width('100%') + Text(this.modelMeta(model)) + .width('100%') + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .constraintSize({ minHeight: MobileDesignGeometry.modelAccountRowHeight }) + .padding({ left: 10, right: 10, top: 8, bottom: 8 }) + .alignItems(VerticalAlign.Center) + .backgroundColor(this.candidateSelected(model.id) ? SOFT : TRANSPARENT) + .borderRadius(MobileDesignGeometry.composerModelSelectorRowRadius) + .id(`device-model-pick-${model.id}`) + .enabled(!this.locked()) + .opacity(this.locked() ? 0.6 : 1) + .onClick(() => { + this.select(model.id); + }) + } + + /** + * A candidate row's leading mark. The outline glyph is deliberate: every row + * would carry a check if the filled one were used, and the selected row is + * already told apart by its fill, so the mark only has to point at the choice + * the role currently holds. + */ + @Builder + private SelectionMark(selected: boolean) { + Stack({ alignContent: Alignment.Center }) { + if (selected) { + SymbolGlyph($r('sys.symbol.checkmark_circle')) + .fontSize(16) + .fontColor([INK]) + } + } + .width(20) + .height(20) + } + + /** + * No model on this device can serve the role. The pinned row above already + * says what the role is left at, so this line only states that the emptiness + * is the device's answer rather than a failed read. + */ + @Builder + private NoCandidatesLine() { + Text(RemoteI18n.t('settings.model.pickEmpty')) + .width('100%') + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontColor(MUTED) + .padding({ left: 10, right: 10, top: 10, bottom: 10 }) + } + + /** + * The device stopped answering this page — the link dropped, the desktop was + * replaced by an older one, or the read itself failed — so the picker states + * the reason instead of a list. Without this a dropped link read as an empty + * catalog, and the null roles of the closed gate read as the user's choices. + */ + @Builder + private ReasonCard() { + ModelUnavailableCard({ + reason: this.reasonText(), + canRetry: this.view().retry, + busy: this.locked(), + cardId: 'device-model-pick-unavailable', + onRetry: (): void => { + void this.deviceModels.retry(); + } + }) + } + + /** Nothing has been read for this device yet, so there is nothing to list. */ + @Builder + private ReadingCard() { + Text(this.deviceModels.state.statusText.length > 0 ? + this.deviceModels.state.statusText : RemoteI18n.t('status.loadingModelDefaults')) + .width('100%') + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(MUTED) + .padding({ left: 18, right: 18, top: 16, bottom: 16 }) + .backgroundColor(CARD) + .borderRadius(MobileDesignGeometry.settingsCardRadius) + .id('device-model-pick-reading') + } + + /** + * The two labels a candidate row carries. The catalog is the protocol's own + * shape, so it is projected through the shared presentation mapper before the + * shared label policy reads it — one boundary, one naming rule. + */ + private modelLabel(model: RemoteModelConfig): string { + return ConversationModelPresentationPolicy.primaryLabel( + toConversationUiModel(model), RemoteI18n.t('chat.model')); + } + + private modelMeta(model: RemoteModelConfig): string { + return ConversationModelPresentationPolicy.modelContextLabel(toConversationUiModel(model)); + } + + private role(): RemoteModelDefaultsRole { + const role = this.sheetState.pickRole; + if (role === 'primary' || role === 'fast' || role === 'image_understanding') { + return role; + } + return 'primary'; + } + + /** The picker knows which slot it is editing; without one it must not write. */ + private hasRole(): boolean { + return this.role() === this.sheetState.pickRole; + } + + private allowsUnset(): boolean { + return ModelRoleCandidatesPolicy.allowsUnset(this.role()); + } + + private showsCapabilityBadge(): boolean { + return ModelRoleCandidatesPolicy.showsCapabilityBadge(this.role()); + } + + private candidates(): RemoteModelConfig[] { + return ModelRoleCandidatesPolicy.candidatesForRole(this.role(), this.deviceModels.state.catalog); + } + + /** What this frame may show, decided from the one state the page renders. */ + private view(): ModelPickerView { + return ModelRoleCandidatesPolicy.pickerView(this.deviceModels.state); + } + + /** + * The reason the card carries: the controller's own sentence for a state it + * judged, or the host's words when a read failed. The page never invents one. + */ + private reasonText(): string { + const error = this.deviceModels.state.error; + return error.length > 0 ? error : RemoteI18n.t('status.remoteUnavailable'); + } + + /** + * Whether the pinned row carries the check. A role's value is only an answer + * while the device is answering: after the link dropped, every role reads as + * null, and drawing that as "this is your setting" would state a choice the + * user never made and the device never confirmed. + */ + private pinSelected(): boolean { + return this.view().answered && + ModelRoleCandidatesPolicy.isUnsetSelected(this.deviceModels.state.roles, this.role()); + } + + private candidateSelected(modelId: string): boolean { + return this.view().answered && ModelRoleCandidatesPolicy.isCandidateSelected( + this.deviceModels.state.roles, this.role(), modelId); + } + + private locked(): boolean { + return this.deviceModels.state.busy; + } + + private roleLabel(): string { + return RemoteI18n.t(ModelRoleCandidatesPolicy.roleLabelKey(this.role())); + } + + private deviceLabel(): string { + return this.desktopName.length > 0 ? this.desktopName : this.desktopId; + } + + /** + * Writes the role and returns to the page that shows the result. The write is + * not awaited here: the settings page is what reports it, and it is the page + * the user is left looking at. + */ + private select(modelId: string | null): void { + if (this.locked() || !this.hasRole()) { + return; + } + void this.deviceModels.selectRole(this.role(), modelId); + this.sheetState.backFromModelPick(); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelUnavailableCard.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelUnavailableCard.ets new file mode 100644 index 0000000000..fd6f75d6c5 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelUnavailableCard.ets @@ -0,0 +1,63 @@ +import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CARD, INK, SOFT, STATUS_DANGER } from './Theme'; + +/** + * Why a model page cannot be answered, and the way to ask again. + * + * Both pages of the device model settings can be open when the desktop stops + * answering: the settings page when the phone is not connected to it, and the + * picker on top of it when the link drops while the list is up. The reason + * belongs to neither page — the controller already decided which sentence + * describes the state — so it is rendered here, once, in the sheet's own card + * language. + * + * The retry is offered only when repeating the read can actually help, which the + * caller decides: a gate that is closed re-arms by itself when the desktop comes + * back, and a button that cannot do anything is worse than no button. + */ +@ComponentV2 +export struct ModelUnavailableCard { + /** The controller's resolved reason, or the host's own words for a failed read. */ + @Param reason: string = ''; + /** Repeat the read that failed. */ + @Param canRetry: boolean = false; + /** A request is in flight, so the page does not take a second one. */ + @Param busy: boolean = false; + /** Distinguishes the two pages' cards in the layout hierarchy. */ + @Param cardId: string = 'model-unavailable'; + @Event onRetry: () => void = () => {}; + + build() { + Column({ space: 10 }) { + Text(this.reason) + .width('100%') + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(STATUS_DANGER) + .maxLines(3) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + if (this.canRetry) { + Text(RemoteI18n.t('common.retry')) + .fontSize(MobileDesignTypography.bodyMedium.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .padding({ left: 10, right: 10, top: 6, bottom: 6 }) + .backgroundColor(SOFT) + .borderRadius(14) + .id(`${this.cardId}-retry`) + .enabled(!this.busy) + .opacity(this.busy ? 0.6 : 1) + .onClick(() => { + this.onRetry(); + }) + } + } + .width('100%') + .alignItems(HorizontalAlign.Start) + .padding({ left: 18, right: 18, top: 16, bottom: 16 }) + .backgroundColor(CARD) + .borderRadius(MobileDesignGeometry.settingsCardRadius) + .id(this.cardId) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets index d90d6044b8..fbbe311a8d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets @@ -1,15 +1,18 @@ import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { SettingsSheetState } from '../state/SettingsSheetState'; +import { DeviceModelDefaultsRequestPath } from '../state/DeviceModelDefaultsSheetState'; import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; import { CurrentControlSection } from './CurrentControlSection'; import { AccountProfilePanel } from './AccountProfilePanel'; +import { DeviceModelDefaultsPanel } from './DeviceModelDefaultsPanel'; +import { ModelRolePickerPanel } from './ModelRolePickerPanel'; import { LanguageSettingsPanel } from './LanguageSettingsPanel'; import { SheetCloseHeader } from './SheetCloseHeader'; import { SHEET_HORIZONTAL_PADDING, SHEET_TOP_RADIUS } from './SheetLayout'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; -import { RemotePermissionMode } from '../../model/RemoteModels'; +import { RemoteDefaultModels, RemoteModelCatalog, RemotePermissionMode } from '../../model/RemoteModels'; import { RemotePermissionSection } from './RemotePermissionSection'; @ComponentV2 @@ -25,6 +28,8 @@ export struct SettingsSheet { @Param controlTargetType: string = 'none'; @Param controlTargetDeviceId: string = ''; @Param connectionState: string = 'idle'; + /** The connected desktop advertises `device_model_defaults_v1`. */ + @Param supportsDeviceModelDefaults: boolean = false; @Param statusText: string = ''; @Event onClose: () => void = () => {}; @Event onOpenAccount: () => void = () => {}; @@ -39,6 +44,25 @@ export struct SettingsSheet { @Event getPermissionMode: () => Promise = async (): Promise => 'ask'; @Event setPermissionMode: (mode: RemotePermissionMode) => Promise = async (mode: RemotePermissionMode): Promise => mode; + /** Device-level model catalog of the connected desktop (no session id). */ + @Event getDeviceModelCatalog: () => Promise = + async (): Promise => ({ version: 0, models: [], default_models: {} }); + /** Whole-object write of the three phone-managed model slots. */ + @Event setDeviceModelDefaults: ( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ) => Promise = async ( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ): Promise => { + return { + primary, + fast, + image_understanding: imageUnderstanding + }; + }; @Event onSetLanguage: (language: string) => Promise = async (_language: string) => {}; /** The sheet's own bottom edge, so its scrolling rows can run under the bar and still rest above it. */ @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); @@ -48,16 +72,66 @@ export struct SettingsSheet { if (context) { this.insets.bind(this.getUIContext(), context); } + // The device model pages are answered by the sheet, not by themselves: the + // desktop they read and write is whichever one this sheet is open on, so the + // request path is bound here before either page can mount. + const modelDefaultsClient: DeviceModelDefaultsRequestPath = { + getModelCatalog: (): Promise => this.getDeviceModelCatalog(), + setModelDefaults: ( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ): Promise => { + return this.setDeviceModelDefaults(primary, fast, imageUnderstanding); + } + }; + this.sheetState.deviceModels.bind(modelDefaultsClient); + this.applyDeviceModelGate(); } aboutToDisappear(): void { this.insets.unbind(); } + /** + * Re-gates the device model pages whenever the desktop, the link or its + * capability set changes. + * + * Arming the gate is not reading: the page spends the read when it opens, so a + * sheet that is opened for the language setting does not ask the desktop about + * its models. A gate that changes while one of the two pages is already open is + * the other case — what is on screen belongs to the previous desktop — so that + * one is read again immediately. + */ + private applyDeviceModelGate(): void { + const needsRead = this.sheetState.deviceModels.setGate({ + supported: this.supportsDeviceModelDefaults, + connected: this.connectionState === 'connected', + deviceId: this.controlTargetDeviceId + }); + if (needsRead && this.onDeviceModelPage()) { + void this.sheetState.deviceModels.load(); + } + } + + private onDeviceModelPage(): boolean { + const page = this.sheetState.page; + return page === 'deviceModels' || page === 'modelPick'; + } + + @Monitor('connectionState', 'supportsDeviceModelDefaults', 'controlTargetDeviceId') + private onDeviceModelTargetChanged(): void { + this.applyDeviceModelGate(); + } + build() { Stack({ alignContent: Alignment.TopEnd }) { if (this.sheetState.page === 'language') { this.LanguagePanel() + } else if (this.sheetState.page === 'deviceModels') { + this.DeviceModelSettingsPage() + } else if (this.sheetState.page === 'modelPick') { + this.ModelRolePickerPage() } else { this.SettingsHome() } @@ -96,11 +170,15 @@ export struct SettingsSheet { controlTargetType: this.controlTargetType, controlTargetDeviceId: this.controlTargetDeviceId, connectionState: this.connectionState, + supportsDeviceModelDefaults: this.supportsDeviceModelDefaults, cloudLogout: this.cloudLogout, cloudListDevices: this.cloudListDevices, cloudSelectDevice: this.cloudSelectDevice, onReconnect: this.onReconnect, onOpenAccount: this.onOpenAccount, + onOpenDeviceModelSettings: () => { + this.sheetState.setPage('deviceModels'); + }, onLoggedOut: this.onClose, connection: () => { this.CurrentConnectionSection() @@ -276,4 +354,33 @@ export struct SettingsSheet { } }) } + + /** + * Device model role settings, reached from the connected device row: the second + * level of the sheet's own page stack. + */ + @Builder + DeviceModelSettingsPage() { + DeviceModelDefaultsPanel({ + sheetState: this.sheetState, + deviceModels: this.sheetState.deviceModels, + desktopName: this.desktopName, + desktopId: this.desktopId, + controlTargetType: this.controlTargetType, + connectionState: this.connectionState, + onClose: this.onClose + }) + } + + /** The role picker pushed above it; both pages share the sheet's controller. */ + @Builder + ModelRolePickerPage() { + ModelRolePickerPanel({ + sheetState: this.sheetState, + deviceModels: this.sheetState.deviceModels, + desktopName: this.desktopName, + desktopId: this.desktopId, + onClose: this.onClose + }) + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/DeviceModelSettingsEntryPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/DeviceModelSettingsEntryPolicy.ets new file mode 100644 index 0000000000..7a2ec93b0c --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/DeviceModelSettingsEntryPolicy.ets @@ -0,0 +1,66 @@ +import { REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1 } from '../../model/RemoteModels'; + +/** + * Entry rules for the device model settings page. + * + * The page reads and writes the desktop the phone is driving right now, so the + * entry belongs to that row and to no other: an offline row has no link to + * answer with, and an online desktop this phone is not connected to would + * promise a read the client cannot perform. Capability, link and row identity + * are decided here together so the row, its badge and its reason line can never + * disagree. + */ +export class DeviceModelSettingsEntryPolicy { + /** One-line reason shown when the degraded entry is tapped. */ + static readonly DEGRADE_REASON_KEY: string = 'settings.model.unsupported'; + + /** The host advertises the device model defaults command. */ + static supportsDeviceModelDefaults(capabilities: string[]): boolean { + return (capabilities || []).indexOf(REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1) >= 0; + } + + /** Whether the device row carries the entry at all. */ + static showsEntry( + online: boolean, + deviceId: string, + phoneDeviceId: string, + controlTargetDeviceId: string, + connectionState: string + ): boolean { + return DeviceModelSettingsEntryPolicy.isDrivenTarget( + deviceId, phoneDeviceId, controlTargetDeviceId, connectionState + ) && online; + } + + /** Whether the entry opens the page. */ + static entryEnabled(supportsDeviceModelDefaults: boolean): boolean { + return supportsDeviceModelDefaults; + } + + /** + * The corner badge for an older desktop: the entry stays visible so the user + * learns why it is unavailable, and is greyed rather than silently missing. + */ + static showsUpgradeBadge(showsEntry: boolean, supportsDeviceModelDefaults: boolean): boolean { + return showsEntry && !supportsDeviceModelDefaults; + } + + /** The reason line to show, or an empty string when the entry is usable. */ + static degradeReasonKey(showsEntry: boolean, supportsDeviceModelDefaults: boolean): string { + return DeviceModelSettingsEntryPolicy.showsUpgradeBadge(showsEntry, supportsDeviceModelDefaults) ? + DeviceModelSettingsEntryPolicy.DEGRADE_REASON_KEY : ''; + } + + /** The desktop this phone is connected to and controlling, by row identity. */ + private static isDrivenTarget( + deviceId: string, + phoneDeviceId: string, + controlTargetDeviceId: string, + connectionState: string + ): boolean { + return deviceId.length > 0 && + deviceId !== phoneDeviceId && + deviceId === controlTargetDeviceId && + connectionState === 'connected'; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ModelRoleCandidatesPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ModelRoleCandidatesPolicy.ets new file mode 100644 index 0000000000..b3c3f25c55 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ModelRoleCandidatesPolicy.ets @@ -0,0 +1,324 @@ +import { RemoteModelCatalog, RemoteModelConfig } from '../../model/RemoteModels'; +import { + RemoteModelDefaultsRole, + RemoteModelDefaultsRoles, + RemoteModelDefaultsState +} from '../../services/RemoteModelDefaultsController'; + +/** + * The vocabulary the two device-model pages share. + * + * Which models a role may be set to, which of them the picker offers, and what a + * role row says are one reading of one catalog, so they are decided here as pure + * functions over it: no i18n lookup, no controller, no ArkUI. The pages resolve + * the keys this returns and own every pixel. + * + * The desktop is the authority. A role's value is whatever the last device read + * returned; a candidate is a model the desktop itself lists as enabled and + * capable, in the desktop's own order. Nothing here reorders, renames or repairs + * that list. + */ + +/** + * What the role picker shows for one device state. + * + * The picker is a list only while the device is actually answering. A snapshot + * that the gate replaced with "unavailable" still carries its last roles, and a + * role that is null there reads as a choice the user made — "fast follows the + * primary" on a desktop that was never asked, or on one the phone can no longer + * reach. So the marks, the list and the sentence about an empty list are decided + * here together with the reason, from the same state the reason comes from. + */ +export interface ModelPickerView { + /** The reason card: the device refused the page, or the last read failed. */ + reason: boolean; + /** The retry: the failure is a read this device can repeat. */ + retry: boolean; + /** The reading card: nothing has been read for this device yet. */ + reading: boolean; + /** The pinned row and the candidates are drawn. */ + list: boolean; + /** + * The rows on screen are the device's own answer, so a selected mark and an + * empty list may be read from them. A closed gate, a failed read or a page that + * has not been read yet all say no. + */ + answered: boolean; +} + +/** What one role row of the device model settings page shows. */ +export interface ModelRoleRowProjection { + /** The model the role points at; empty when the role has no model of its own. */ + modelId: string; + /** The catalog entry for `modelId`, when the desktop still lists it. */ + model: RemoteModelConfig | undefined; + /** The role has no model of its own: `fast` follows the primary, the rest are off. */ + unset: boolean; + /** The value line's placeholder key; empty while `modelId` names a model. */ + unsetKey: string; + /** The hint under the value; empty unless the role explicitly falls back. */ + hintKey: string; + /** + * The role resolves to an enabled model the desktop lists — its own, or for + * `fast` the primary it falls back to. This is what the row's leading mark + * states. + */ + resolved: boolean; +} + +export class ModelRoleCandidatesPolicy { + /** Model capabilities, as the desktop reports them. */ + static readonly CAPABILITY_TEXT_CHAT: string = 'text_chat'; + static readonly CAPABILITY_IMAGE_UNDERSTANDING: string = 'image_understanding'; + + /** The neutral "nothing is set here" value, shared with the model panels. */ + static readonly NOT_CONFIGURED_KEY: string = 'settings.modelService.notConfigured'; + + /** + * Models the picker may offer for a role: enabled, capable, in catalog order. + * + * A disabled model is not a candidate at all — the desktop would refuse the + * write, and offering it would turn a filter into an error message — and the + * capability is what makes the model usable for that role in the first place. + */ + static textChatCandidates(catalog: RemoteModelCatalog): RemoteModelConfig[] { + return ModelRoleCandidatesPolicy.capableModels( + catalog, ModelRoleCandidatesPolicy.CAPABILITY_TEXT_CHAT); + } + + static imageUnderstandingCandidates(catalog: RemoteModelCatalog): RemoteModelConfig[] { + return ModelRoleCandidatesPolicy.capableModels( + catalog, ModelRoleCandidatesPolicy.CAPABILITY_IMAGE_UNDERSTANDING); + } + + static candidatesForRole( + role: RemoteModelDefaultsRole, + catalog: RemoteModelCatalog + ): RemoteModelConfig[] { + return role === 'image_understanding' ? + ModelRoleCandidatesPolicy.imageUnderstandingCandidates(catalog) : + ModelRoleCandidatesPolicy.textChatCandidates(catalog); + } + + /** The desktop lists no model at all: the pickers have nothing to offer. */ + static isEmptyCatalog(catalog: RemoteModelCatalog): boolean { + return catalog.models.length === 0; + } + + /** + * The device cannot answer this page: it never advertised the command, or the + * phone is not connected to it, or the read itself failed. All three are the + * same fact to the picker — the list on screen is not an answer — and the + * controller has already resolved which sentence describes it into `error`. + */ + static isUnavailable(state: RemoteModelDefaultsState): boolean { + return !state.available || state.error.length > 0; + } + + /** + * Whether repeating the read is a way out. Only a device that can still answer + * can be asked again: a closed gate is the sheet's own business, and it re-arms + * by itself when the link or the desktop comes back. + */ + static canRepeatRead(state: RemoteModelDefaultsState): boolean { + return state.available && state.error.length > 0; + } + + /** The render decisions of one picker frame, all read from one state. */ + static pickerView(state: RemoteModelDefaultsState): ModelPickerView { + const reason = ModelRoleCandidatesPolicy.isUnavailable(state); + const reading = state.available && !reason && !state.loaded; + return { + reason, + retry: ModelRoleCandidatesPolicy.canRepeatRead(state), + reading, + // A device that can still answer keeps its list even after a failed read: + // those rows are the last thing it said, and they are not what failed. + list: state.available && !reading, + answered: !reason && !reading + }; + } + + /** + * Whether the picker offers the "no model" row. + * + * The primary model is required — the desktop always resolves one, and a phone + * that cleared it could leave the device with no model to run — so its picker + * has no such row. The other two are optional by contract: an empty `fast` is + * "follow the primary" and an empty `image_understanding` is "off". + */ + static allowsUnset(role: RemoteModelDefaultsRole): boolean { + return role !== 'primary'; + } + + /** The pinned row's label key. */ + static unsetLabelKey(role: RemoteModelDefaultsRole): string { + if (role === 'fast') { + return 'chat.modelFollowPrimary'; + } + return ModelRoleCandidatesPolicy.NOT_CONFIGURED_KEY; + } + + /** The pinned row's hint key. */ + static unsetHintKey(role: RemoteModelDefaultsRole): string { + if (role === 'fast') { + return 'chat.modelFastNotConfigured'; + } + if (role === 'image_understanding') { + return 'settings.model.imageUnderstandingUnset'; + } + return ''; + } + + /** The role's own label, shared by the rows, the picker title and the sent row. */ + static roleLabelKey(role: RemoteModelDefaultsRole): string { + if (role === 'primary') { + return 'chat.modelPrimary'; + } + if (role === 'fast') { + return 'chat.modelFast'; + } + if (role === 'image_understanding') { + return 'chat.modelImage'; + } + return ''; + } + + /** The filter sentence above the candidate list, templated with the device name. */ + static filterKey(role: RemoteModelDefaultsRole): string { + return role === 'image_understanding' ? + 'settings.model.filterImageUnderstanding' : 'settings.model.filterTextChat'; + } + + /** + * The capability badge belongs to the multimodal picker only: it explains why + * that list is short, and on any other page it would only widen every row. + */ + static showsCapabilityBadge(role: RemoteModelDefaultsRole): boolean { + return role === 'image_understanding'; + } + + /** The model id a role currently holds; empty when it holds none. */ + static selectedValue(roles: RemoteModelDefaultsRoles, role: RemoteModelDefaultsRole): string { + if (role === 'primary') { + return roles.primary || ''; + } + if (role === 'fast') { + return roles.fast || ''; + } + if (role === 'image_understanding') { + return roles.image_understanding || ''; + } + return ''; + } + + /** True while the role is set to "no model", i.e. the pinned row is the checked one. */ + static isUnsetSelected(roles: RemoteModelDefaultsRoles, role: RemoteModelDefaultsRole): boolean { + return ModelRoleCandidatesPolicy.allowsUnset(role) && + ModelRoleCandidatesPolicy.selectedValue(roles, role).length === 0; + } + + /** True when this candidate row is the role's current model. */ + static isCandidateSelected( + roles: RemoteModelDefaultsRoles, + role: RemoteModelDefaultsRole, + modelId: string + ): boolean { + return ModelRoleCandidatesPolicy.selectedValue(roles, role) === modelId; + } + + /** + * One role row of the settings page. + * + * An empty catalog is its own answer: with no model on the device there is + * nothing for any role to point at, so every row states the same unconfigured + * value and no row claims to resolve. Saying "follows the primary" there would + * promise a fallback the desktop cannot perform. + */ + static roleRow( + roles: RemoteModelDefaultsRoles, + role: RemoteModelDefaultsRole, + catalog: RemoteModelCatalog + ): ModelRoleRowProjection { + if (ModelRoleCandidatesPolicy.isEmptyCatalog(catalog)) { + return { + modelId: '', + model: undefined, + unset: true, + unsetKey: ModelRoleCandidatesPolicy.NOT_CONFIGURED_KEY, + hintKey: '', + resolved: false + }; + } + const modelId = ModelRoleCandidatesPolicy.selectedValue(roles, role); + const model = ModelRoleCandidatesPolicy.modelById(catalog, modelId); + const unset = modelId.length === 0; + return { + modelId, + model, + unset, + unsetKey: unset ? ModelRoleCandidatesPolicy.unsetLabelKey(role) : '', + hintKey: unset ? ModelRoleCandidatesPolicy.unsetHintKey(role) : '', + resolved: ModelRoleCandidatesPolicy.resolves(roles, role, catalog) + }; + } + + /** + * The model a role actually runs: its own, or for `fast` the primary model it + * falls back to. `image_understanding` has no fallback — an empty one means the + * capability is off rather than borrowed from another slot. + * + * A role only runs an enabled model, so a slot the desktop points at a disabled + * model resolves to nothing here even though the row still names it. This is + * the same reading of a catalog the composer's role rows use. + */ + static resolvedModel( + roles: RemoteModelDefaultsRoles, + role: RemoteModelDefaultsRole, + catalog: RemoteModelCatalog + ): RemoteModelConfig | undefined { + const own = ModelRoleCandidatesPolicy.enabledModelById( + catalog, ModelRoleCandidatesPolicy.selectedValue(roles, role)); + if (own) { + return own; + } + if (role === 'fast') { + return ModelRoleCandidatesPolicy.enabledModelById(catalog, roles.primary || ''); + } + return undefined; + } + + private static resolves( + roles: RemoteModelDefaultsRoles, + role: RemoteModelDefaultsRole, + catalog: RemoteModelCatalog + ): boolean { + return ModelRoleCandidatesPolicy.resolvedModel(roles, role, catalog) !== undefined; + } + + private static enabledModelById( + catalog: RemoteModelCatalog, + modelId: string + ): RemoteModelConfig | undefined { + const model = ModelRoleCandidatesPolicy.modelById(catalog, modelId); + return model && model.enabled ? model : undefined; + } + + private static modelById( + catalog: RemoteModelCatalog, + modelId: string + ): RemoteModelConfig | undefined { + if (modelId.length === 0) { + return undefined; + } + return catalog.models.find((model: RemoteModelConfig): boolean => model.id === modelId); + } + + private static capableModels( + catalog: RemoteModelCatalog, + capability: string + ): RemoteModelConfig[] { + return catalog.models.filter((model: RemoteModelConfig): boolean => + model.enabled && (model.capabilities || []).indexOf(capability) >= 0); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/DeviceModelDefaultsPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/DeviceModelDefaultsPreview.ets new file mode 100644 index 0000000000..7240c48378 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/DeviceModelDefaultsPreview.ets @@ -0,0 +1,301 @@ +import { + REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1, + RemoteDefaultModels, + RemoteModelCatalog, + RemoteModelConfig +} from '../../model/RemoteModels'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { DeviceModelDefaultsRequestPath } from '../state/DeviceModelDefaultsSheetState'; +import { AppRootPresentationActions, emptyAppRootPresentationActions } from '../actions/AppRootPresentationActions'; +import { AppSettingsSurface } from '../components/AppRootOverlaySurfaces'; +import { AppShell } from '../components/AppShell'; +import { PAGE_BG } from '../components/Theme'; +import { AppShellState } from '../state/AppShellState'; +import { RemotePageState } from '../state/RemotePageState'; + +/** + * The device model pages over a fixture desktop: the production sheet, its two + * model pages and the production controller, with the device's answers supplied + * locally. + * + * Nothing here is a picture of a page. The scenario id picks a device state — how + * many models it has, whether it can serve the page at all, whether a write is + * still in flight — and the sheet is mounted the way the app mounts it, so every + * state on screen is one the app can actually reach, and the interaction between + * them (tapping a role, choosing a model, watching the read-back land) works. + */ + +/** One model of the fixture desktop. */ +function previewModel( + id: string, + name: string, + provider: string, + modelName: string, + contextWindow: number, + enabled: boolean, + capabilities: string[] +): RemoteModelConfig { + return { + id, + name, + provider, + base_url: '', + model_name: modelName, + context_window: contextWindow, + enabled, + capabilities + }; +} + +/** + * The fixture desktop's catalog: four roles' worth of models, so `fast` and the + * multimodal slot can be told apart — one text-only model, one that carries both + * capabilities, one multimodal-only model, and one disabled model that no list + * may offer. + */ +function previewModels(): RemoteModelConfig[] { + return [ + previewModel('anthropic/claude-sonnet-4.5', 'Claude Sonnet 4.5', 'anthropic', + 'claude-sonnet-4-5', 200000, true, ['text_chat']), + previewModel('openbitfun:gpt-5-mini', 'GPT-5 mini', 'openai', + 'openbitfun:gpt-5-mini', 128000, true, ['text_chat']), + previewModel('google/gemini-2.5-flash', 'Gemini 2.5 Flash', 'google', + 'gemini-2.5-flash', 1000000, true, ['text_chat', 'image_understanding']), + previewModel('local:qwen3-coder', 'Qwen3 Coder', 'local', + 'qwen3-coder', 32768, true, ['text_chat']), + previewModel('preview/vision-preview', 'Vision Preview', 'openbitfun', + 'vision-preview', 64000, true, ['image_understanding']), + previewModel('legacy/disabled-model', 'Legacy model', 'legacy', + 'legacy-model', 8192, false, ['text_chat']) + ]; +} + +/** A primary model and two empty slots: the state the page opens on. */ +function previewDefaults(): RemoteDefaultModels { + return { + primary: 'anthropic/claude-sonnet-4.5', + fast: null, + image_understanding: null + }; +} + +/** The model the two write-driven scenarios select for the fast slot. */ +const PREVIEW_WRITE_MODEL: string = 'openbitfun:gpt-5-mini'; + +/** + * The fixture desktop's request path. + * + * `hangWrites` is the one behaviour a static fixture cannot express any other + * way: an answer that never arrives is what the locked page looks like, and it + * cannot be faked by a state flag without also faking the busy lock that the + * controller derives from the request being out. + */ +class PreviewModelDefaultsClient implements DeviceModelDefaultsRequestPath { + hangWrites: boolean = false; + /** The desktop stops answering the catalog read, which is a state of its own. */ + failReads: boolean = false; + private models: RemoteModelConfig[]; + private stored: RemoteDefaultModels; + + constructor(models: RemoteModelConfig[], stored: RemoteDefaultModels) { + this.models = models; + this.stored = stored; + } + + // Declared as fields rather than methods: the pages bind the request path as a + // property-typed shape, and ArkTS does not treat a method as one of those. + getModelCatalog: (sessionId?: string) => Promise = + async (): Promise => { + if (this.failReads) { + throw new Error('The desktop stopped answering the model catalog.'); + } + return { + version: 1, + models: this.models.slice(), + default_models: this.stored + }; + }; + + setModelDefaults: ( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ) => Promise = async ( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ): Promise => { + if (this.hangWrites) { + return new Promise((): void => {}); + } + this.stored = { + primary, + fast, + search: this.stored.search, + image_understanding: imageUnderstanding, + image_generation: this.stored.image_generation, + speech_recognition: this.stored.speech_recognition + }; + return this.stored; + }; +} + +/** + * The settings surface the production shell mounts, over the fixture's device + * catalog. Only the two device-model requests differ from the app's own wiring. + */ +function previewActions(client: PreviewModelDefaultsClient): AppRootPresentationActions { + const base = emptyAppRootPresentationActions(); + return { + onWorkspaceTools: base.onWorkspaceTools, + onNavigationBack: base.onNavigationBack, + onConversationIntent: base.onConversationIntent, + onCloseSidebar: base.onCloseSidebar, + onCompactLayoutEntered: base.onCompactLayoutEntered, + onLayoutModeChanged: base.onLayoutModeChanged, + onRemoteHome: base.onRemoteHome, + onSidebar: base.onSidebar, + onSettings: { + close: base.onSettings.close, + accountSettled: base.onSettings.accountSettled, + disconnect: base.onSettings.disconnect, + clearPairing: base.onSettings.clearPairing, + reconnect: base.onSettings.reconnect, + openAccount: base.onSettings.openAccount, + cloudCancelLogin: base.onSettings.cloudCancelLogin, + cloudLogin: base.onSettings.cloudLogin, + cloudLogout: base.onSettings.cloudLogout, + cloudListDevices: async (): Promise => [], + cloudSelectDevice: base.onSettings.cloudSelectDevice, + getPermissionMode: base.onSettings.getPermissionMode, + setPermissionMode: base.onSettings.setPermissionMode, + getDeviceModelCatalog: (): Promise => client.getModelCatalog(), + setDeviceModelDefaults: ( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ): Promise => { + return client.setModelDefaults(primary, fast, imageUnderstanding); + }, + setLanguage: base.onSettings.setLanguage + }, + onConnect: base.onConnect, + onFilePreview: base.onFilePreview, + generalStatus: base.generalStatus + }; +} + +@ComponentV2 +export struct DeviceModelDefaultsPreview { + @Local shellState: AppShellState = new AppShellState(); + @Local remotePageState: RemotePageState = new RemotePageState(); + @Local actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + private client: PreviewModelDefaultsClient = + new PreviewModelDefaultsClient(previewModels(), previewDefaults()); + + aboutToAppear(): void { + const scenario = this.scenarioId(); + this.client = new PreviewModelDefaultsClient( + scenario === 'device-model-empty' ? [] : previewModels(), + previewDefaults() + ); + this.client.hangWrites = scenario === 'device-model-writing'; + this.client.failReads = scenario === 'device-model-pick-failed'; + this.remotePageState.setAccountUserId('octocat'); + this.remotePageState.setAccountUsername('octocat'); + this.remotePageState.setControlTarget('account_device', 'account-desktop-1', 'Account desktop 1'); + this.remotePageState.setDesktopIdentity('Account desktop 1', 'account-desktop-1'); + this.remotePageState.connectionState = 'connected'; + // An older desktop is one that never advertised the command, so the page is + // reached through the same capability check the app uses. + this.remotePageState.setHostCapabilities(this.degraded(scenario) ? + ['workspace_id_references_v1'] : [REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1]); + this.actions = previewActions(this.client); + this.shellState.showSettings = true; + this.shellState.settingsSheet.setPage('deviceModels'); + const role = this.pickerRole(scenario); + if (role.length > 0) { + this.shellState.settingsSheet.openModelPick(role); + } + if (scenario === 'device-model-writing' || scenario === 'device-model-updated') { + void this.driveWrite(); + } + } + + /** + * Runs the write the scenario is about, before the first frame. + * + * The sheet arms the same gate a moment later from its own parameters, so this + * only has to happen first: a scenario that has to show a write in flight — or + * its result — cannot wait for the page to be tapped. + */ + private async driveWrite(): Promise { + const deviceModels = this.shellState.settingsSheet.deviceModels; + deviceModels.bind(this.client); + deviceModels.setGate({ supported: true, connected: true, deviceId: 'account-desktop-1' }); + await deviceModels.ensureLoaded(); + await deviceModels.selectRole('fast', PREVIEW_WRITE_MODEL); + } + + private scenarioId(): string { + const scenario = AppStorage.get('scenarioId') ?? ''; + return scenario.endsWith('-dark') ? scenario.slice(0, scenario.length - 5) : scenario; + } + + /** The desktop never advertised the command, on the settings page or its picker. */ + private degraded(scenario: string): boolean { + return scenario === 'device-model-degraded' || scenario === 'device-model-degraded-pick'; + } + + /** The role the picker is opened on, or an empty string for the settings page. */ + private pickerRole(scenario: string): string { + if (scenario === 'device-model-pick-fast') { + return 'fast'; + } + if (scenario === 'device-model-pick-multimodal') { + return 'image_understanding'; + } + if (scenario === 'device-model-pick-primary') { + return 'primary'; + } + // The picker in the two states it has no list for: a desktop that cannot + // answer this page at all, and one that stopped answering it. + if (scenario === 'device-model-degraded-pick' || scenario === 'device-model-pick-failed') { + return 'fast'; + } + return ''; + } + + build() { + AppShell({ + shellState: this.shellState, + allowSidebar: true, + content: () => { + this.EmptyConversation() + }, + settings: () => { + this.Settings() + }, + onCloseSidebar: () => { + this.shellState.showSidebar = false; + } + }) + } + + @Builder + private EmptyConversation() { + Column() + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } + + @Builder + private Settings() { + AppSettingsSurface({ + shellState: this.shellState, + remotePageState: this.remotePageState, + actions: this.actions + }) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets index 0066fb2a97..1356cbfb69 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets @@ -1,5 +1,10 @@ import { ObservableChatTimelineItem } from '../../model/ChatTimelineModels'; -import { ChatMessage, RecentWorkspaceEntry, RemoteSession } from '../../model/RemoteModels'; +import { + ChatMessage, + RecentWorkspaceEntry, + RemoteSession, + REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1 +} from '../../model/RemoteModels'; import { MiniAppSummary } from '../../model/MiniAppModels'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { ChatSurface } from '../state/ChatSurface'; @@ -219,6 +224,8 @@ function previewActionsWithDevices(devices: CloudAccountDevice[]): AppRootPresen cloudSelectDevice: base.onSettings.cloudSelectDevice, getPermissionMode: base.onSettings.getPermissionMode, setPermissionMode: base.onSettings.setPermissionMode, + getDeviceModelCatalog: base.onSettings.getDeviceModelCatalog, + setDeviceModelDefaults: base.onSettings.setDeviceModelDefaults, setLanguage: base.onSettings.setLanguage }, onConnect: base.onConnect, @@ -339,6 +346,11 @@ export struct ImmersiveBottomPreview { this.remotePageState.setControlTarget('account_device', 'account-desktop-1', 'Account desktop 1'); this.remotePageState.setDesktopIdentity('Account desktop 1', 'account-desktop-1'); this.remotePageState.connectionState = 'connected'; + // The model settings entry on the controlled desktop's row is offered or + // greyed by the desktop's own capability set, and an empty set can only ever + // be the greyed state. This fixture is the connected desktop's, so it + // advertises the command and the sheet shows the entry it really shows. + this.remotePageState.setHostCapabilities([REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1]); this.previewActions = previewActionsWithDevices(settingsDevices()); return; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets index 229b30ea72..512d3e113c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets @@ -11,6 +11,7 @@ import { SubagentProcessPreview } from './SubagentProcessPreview'; import { FileVisibilityPreview } from './FileVisibilityPreview'; import { ComposerOverflowPreview } from './ComposerOverflowPreview'; import { ImmersiveBottomPreview } from './ImmersiveBottomPreview'; +import { DeviceModelDefaultsPreview } from './DeviceModelDefaultsPreview'; import { WelcomeHome } from '../components/WelcomeHome'; import { InteractionMailboxPreview } from './InteractionMailboxPreview'; import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; @@ -68,6 +69,8 @@ struct MobileDesignGallery { ImmersiveBottomPreview() } else if ((AppStorage.get('scenarioId') || '').startsWith('device-selector')) { DeviceSelectorPreview() + } else if ((AppStorage.get('scenarioId') || '').startsWith('device-model')) { + DeviceModelDefaultsPreview() } else if (AppStorage.get('scenarioId') === 'welcome-home') { WelcomeHome() } else if ((AppStorage.get('scenarioId') || '').startsWith('copy-selection')) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets index 5b020b6853..498d47891e 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -8,6 +8,7 @@ import { SidebarConnectionActionPolicy } from '../policy/SidebarConnectionAction import { accountDeviceLink } from '../../services/AccountDeviceLink'; import { ChatMessage, + RemoteDefaultModels, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, @@ -925,6 +926,14 @@ export abstract class AppRootRuntimeComposition { getPermissionMode: (): Promise => this.settingsController.getRemotePermissionMode(), setPermissionMode: (mode: RemotePermissionMode): Promise => this.settingsController.setRemotePermissionMode(mode), + getDeviceModelCatalog: (): Promise => + this.settingsController.getDeviceModelCatalog(), + setDeviceModelDefaults: ( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ): Promise => + this.settingsController.setDeviceModelDefaults(primary, fast, imageUnderstanding), setLanguage: async (language: string): Promise => { await this.localeController.setLanguage(language); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationUiModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationUiModels.ets index 13ec2329d4..773175ea62 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationUiModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationUiModels.ets @@ -182,15 +182,23 @@ export function toConversationUiModelCatalog(source: RemoteModelCatalog): Conver version: source.version, models: source.models.map((model: RemoteModelConfig) => toConversationUiModel(model)), default_models: { - primary: source.default_models.primary, - fast: source.default_models.fast, - search: source.default_models.search, - image_understanding: source.default_models.image_understanding + primary: projectedDefaultModelId(source.default_models.primary), + fast: projectedDefaultModelId(source.default_models.fast), + search: projectedDefaultModelId(source.default_models.search), + image_understanding: projectedDefaultModelId(source.default_models.image_understanding) }, session_model_id: source.session_model_id }; } +/** + * The wire says `null` for a slot with no model; this projection says the same + * by leaving the id absent. Both mean "nothing configured" to every reader. + */ +function projectedDefaultModelId(value: string | null | undefined): string | undefined { + return value === undefined || value === null || value.length === 0 ? undefined : value; +} + export function toConversationUiSelectedImage(source: SelectedImageAttachment): ConversationUiSelectedImage { return { id: source.id, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/DeviceModelDefaultsSheetState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/DeviceModelDefaultsSheetState.ets new file mode 100644 index 0000000000..a0bf242e98 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/DeviceModelDefaultsSheetState.ets @@ -0,0 +1,219 @@ +import { RemoteDefaultModels, RemoteModelCatalog } from '../../model/RemoteModels'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { + RemoteModelDefaultsClient, + RemoteModelDefaultsController, + RemoteModelDefaultsGate, + RemoteModelDefaultsRole, + RemoteModelDefaultsState +} from '../../services/RemoteModelDefaultsController'; +import { ModelRoleCandidatesPolicy } from '../policy/ModelRoleCandidatesPolicy'; + +/** How long the confirmed-write line stays on screen. */ +export const MODEL_DEFAULTS_FEEDBACK_MS: number = 2000; + +/** + * The state a page shows before anything has been read or bound. + * + * It is the same shape `RemoteModelDefaultsController` publishes for a closed + * gate, so a page mounted before its client is bound renders an empty page + * instead of nothing at all. The controller's own snapshot is what a page + * normally sees; `model-role-pages.test.cjs` pins the two together so this copy + * cannot drift away from it. + */ +export function emptyModelDefaultsState(): RemoteModelDefaultsState { + return { + available: false, + loading: false, + busy: false, + catalog: RemoteUiState.emptyModelCatalog(), + roles: { primary: null, fast: null, image_understanding: null }, + error: '', + failure: '', + statusText: '', + loaded: false + }; +} + +/** + * The request path the sheet hands to the pages: the same two calls + * `RemoteModelDefaultsClient` describes, declared as properties. + * + * ArkTS only accepts an object literal against a type whose members are + * properties, and the sheet binds its two request events as a literal, because + * the desktop they go to is chosen by the sheet and not by either page. The + * shape is the same one the controller calls, so this is a spelling of that + * interface rather than a second protocol. + */ +export interface DeviceModelDefaultsRequestPath { + getModelCatalog: (sessionId?: string) => Promise; + setModelDefaults: ( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ) => Promise; +} + +/** + * Device model role defaults for the connected desktop, owned by the settings + * sheet. + * + * The sheet's two model pages (`deviceModels` and the `modelPick` level above + * it) are one workflow over one controller: the picker writes, the settings page + * shows the read-back, and swapping between them must not re-read the device or + * lose a write in flight. So the controller lives here, on the sheet state the + * two pages are both handed, rather than inside either page. + * + * It is also the client the controller calls: `bind` decides which desktop the + * pages are talking to, and the sheet rebinds the same object when it opens on + * another one. Everything else — gating, the write, the read-back, the stale + * result fence — belongs to the controller. + */ +@ObservedV2 +export class DeviceModelDefaultsSheetState implements RemoteModelDefaultsClient { + /** The controller's latest snapshot; the only thing the pages render. */ + @Trace state: RemoteModelDefaultsState = emptyModelDefaultsState(); + /** The role a write is running for, or the one that just failed. */ + @Trace pendingRole: RemoteModelDefaultsRole | '' = ''; + /** The value that write carries; `null` is a value, not an absence. */ + @Trace pendingModelId: string | null = null; + /** True while a confirmed write's result line is on screen. */ + @Trace feedbackVisible: boolean = false; + + private readonly feedbackDurationMs: number; + private readonly controller: RemoteModelDefaultsController; + private target?: DeviceModelDefaultsRequestPath; + private feedbackTimer: number = 0; + + constructor(feedbackDurationMs: number = MODEL_DEFAULTS_FEEDBACK_MS) { + this.feedbackDurationMs = feedbackDurationMs; + this.controller = new RemoteModelDefaultsController(this, { + onStateChanged: (state: RemoteModelDefaultsState): void => { + this.state = state; + } + }); + this.state = this.controller.getState(); + } + + /** Points the pages at one desktop's request path. */ + bind(target: DeviceModelDefaultsRequestPath): void { + this.target = target; + } + + /** Re-gates the pages; true when what is on screen has to be read again. */ + setGate(gate: RemoteModelDefaultsGate): boolean { + return this.controller.setGate(gate); + } + + /** Reads once per gate: a page that opens twice does not read twice. */ + async ensureLoaded(): Promise { + if (this.state.loaded || this.state.loading) { + return; + } + await this.controller.load(); + } + + async load(): Promise { + await this.controller.load(); + } + + /** + * Sets one role and waits for the controller's read-back. + * + * The feedback line is raised only after that read returns, and only when a + * write really happened: selecting the value a role already holds writes + * nothing, and an unavailable gate writes nothing either, so neither may claim + * the device was updated. What the line says is derived from the read-back + * value at render time — the desktop may have repaired the selection, and the + * intent is not the result. + */ + async selectRole(role: RemoteModelDefaultsRole, modelId: string | null): Promise { + // One write at a time, and none while the read that answers the page is still + // out: either one would leave the intent recorded here describing a value no + // request ever carried. + if (this.state.busy || this.state.loading) { + return; + } + const requested = modelId === null ? '' : modelId; + const current = ModelRoleCandidatesPolicy.selectedValue(this.state.roles, role); + this.pendingRole = role; + this.pendingModelId = modelId; + this.clearFeedback(); + if (current === requested) { + return; + } + await this.controller.applyRoleSelection(role, modelId); + if (this.state.available && this.state.error.length === 0) { + this.showFeedback(); + } + } + + /** + * Repeats whatever failed: the write that was rejected, or the read that failed + * after an accepted write. The failure the controller reported decides which, + * because a retry of the wrong one would ask the user to re-answer a question + * they already answered. + */ + async retry(): Promise { + const role = this.pendingRole; + if (this.state.failure === 'write' && role !== '') { + const modelId = this.pendingModelId; + await this.controller.applyRoleSelection(role, modelId); + if (this.state.available && this.state.error.length === 0) { + this.showFeedback(); + } + return; + } + this.clearFeedback(); + await this.controller.load(); + } + + /** Clears the pages while the sheet is closed. */ + reset(): void { + this.clearFeedback(); + this.pendingRole = ''; + this.pendingModelId = null; + this.controller.reset(); + } + + getModelCatalog(sessionId?: string): Promise { + const target = this.target; + if (!target) { + return Promise.reject(new Error('The device model pages have no client bound.')); + } + return target.getModelCatalog(sessionId); + } + + setModelDefaults( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ): Promise { + const target = this.target; + if (!target) { + return Promise.reject(new Error('The device model pages have no client bound.')); + } + return target.setModelDefaults(primary, fast, imageUnderstanding); + } + + private showFeedback(): void { + this.feedbackVisible = true; + this.cancelTimer(); + this.feedbackTimer = setTimeout(() => { + this.feedbackTimer = 0; + this.feedbackVisible = false; + }, this.feedbackDurationMs); + } + + private clearFeedback(): void { + this.cancelTimer(); + this.feedbackVisible = false; + } + + private cancelTimer(): void { + if (this.feedbackTimer !== 0) { + clearTimeout(this.feedbackTimer); + this.feedbackTimer = 0; + } + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SettingsSheetState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SettingsSheetState.ets index a4aca3d561..4ed2410bee 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SettingsSheetState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SettingsSheetState.ets @@ -1,11 +1,25 @@ +import { DeviceModelDefaultsSheetState } from './DeviceModelDefaultsSheetState'; + /** * Settings sheet navigation and editor drafts live here so a fold-driven * rebuild does not send the user back to the home list or wipe in-progress * local-model fields. + * + * It is also where the device model pages' controller lives. Those pages are two + * levels of one workflow over the connected desktop — the settings page and the + * role picker it pushes — so the controller outlives either of them and belongs + * to the sheet state the sheet hands to both. */ @ObservedV2 export class SettingsSheetState { @Trace page: string = 'main'; + /** + * The role the second-level picker edits. Empty while the picker is closed, + * so a page rebuilt from the wrong level cannot open someone else's list. + */ + @Trace pickRole: string = ''; + /** Device model role defaults for the connected desktop; reset when the sheet closes. */ + readonly deviceModels: DeviceModelDefaultsSheetState = new DeviceModelDefaultsSheetState(); @Trace savedGeneralChatApiUrl: string = ''; @Trace savedGeneralChatModelName: string = ''; @Trace savedGeneralChatHasApiKey: boolean = false; @@ -16,9 +30,27 @@ export class SettingsSheetState { @Trace editorDraftHydrated: boolean = false; setPage(page: string): void { + if (page !== 'modelPick') { + this.pickRole = ''; + } this.page = page; } + /** + * Pushes the role picker: the second level of the sheet's own page stack + * (`main` → `deviceModels` → `modelPick`). + */ + openModelPick(role: string): void { + this.pickRole = role; + this.page = 'modelPick'; + } + + /** Returns one level: the picker goes back to the settings page that pushed it. */ + backFromModelPick(): void { + this.pickRole = ''; + this.page = 'deviceModels'; + } + syncSaved(apiUrl: string, modelName: string, hasApiKey: boolean): void { this.savedGeneralChatApiUrl = apiUrl; this.savedGeneralChatModelName = modelName; @@ -38,6 +70,8 @@ export class SettingsSheetState { resetOnClose(): void { this.page = 'main'; + this.pickRole = ''; + this.deviceModels.reset(); this.draftApiUrl = ''; this.draftApiKey = ''; this.draftModelName = ''; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets index ea1b1e6dae..2f73c252f6 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets @@ -17,7 +17,7 @@ import { Encoding } from '../../services/Encoding'; import { RemoteLogger } from '../../services/RemoteLogger'; import { RemoteSessionManager } from '../../services/RemoteSessionManager'; import { WatchProvisionOutcome } from '../../services/WatchProvisionController'; -import { RemotePermissionMode } from '../../model/RemoteModels'; +import { RemoteDefaultModels, RemoteModelCatalog, RemotePermissionMode } from '../../model/RemoteModels'; import { RemotePageState } from '../state/RemotePageState'; export interface CloudAccountSettingsHooks { @@ -480,6 +480,35 @@ export class SettingsController { return cloud.sessionManager.setPermissionMode(mode); } + /** + * The connected desktop's own model catalog: no session id, so the read is + * device scope and carries the reconciled default models. + */ + async getDeviceModelCatalog(): Promise { + const cloud = this.requireCloud(); + if (!cloud.hooks.remoteAvailable()) { + throw new Error(RemoteI18n.t('status.remoteUnavailable')); + } + return cloud.sessionManager.getModelCatalog(); + } + + /** + * Writes the three phone-managed model slots as one object and answers with + * the desktop's reconciled values. `null` is a value: the fast model then + * follows the primary one and the others are left unconfigured. + */ + async setDeviceModelDefaults( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ): Promise { + const cloud = this.requireCloud(); + if (!cloud.hooks.remoteAvailable()) { + throw new Error(RemoteI18n.t('status.remoteUnavailable')); + } + return cloud.sessionManager.setModelDefaults(primary, fast, imageUnderstanding); + } + /** * Puts the account back on the desktop it was last driving. * diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets index 1145a05ae6..55e48358f0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets @@ -181,6 +181,23 @@ export class RemoteCommandFactory { }; } + /** + * Writes the three phone-managed device model slots as one object. + * + * `null` is a value, not an omission: fast follows the primary model and the + * other two are left unconfigured. Every slot is always sent so a host can + * never read an absent key as "leave unchanged", which would make the write + * ambiguous between the two meanings. + */ + static setModelDefaults(primary: string | null, fast: string | null, imageUnderstanding: string | null): RemoteCommand { + return { + cmd: 'set_model_defaults', + primary, + fast, + image_understanding: imageUnderstanding + }; + } + static pollSession( sessionId: string, sinceVersion: number, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteModelDefaultsController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteModelDefaultsController.ets new file mode 100644 index 0000000000..e5f6ecddae --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteModelDefaultsController.ets @@ -0,0 +1,373 @@ +import { RemoteDefaultModels, RemoteModelCatalog } from '../model/RemoteModels'; +import { RemoteI18n } from '../i18n/RemoteI18n'; +import { RemoteUiState } from './RemoteUiState'; + +/** + * The three model slots a phone may write on the controlled desktop. + * + * `null` is a value: the fast model then follows the primary model, and the + * other two are left unconfigured. The slots the phone does not manage + * (`search`, `image_generation`, `speech_recognition`) are never sent. + */ +export interface RemoteModelDefaultsRoles { + primary: string | null; + fast: string | null; + image_understanding: string | null; +} + +/** Roles the sheet can edit, in sheet order. */ +export type RemoteModelDefaultsRole = 'primary' | 'fast' | 'image_understanding'; + +/** + * Device-level model client. + * + * `getModelCatalog()` is called without a session id on purpose: the defaults + * belong to the desktop, not to whichever session happens to be open. + */ +export interface RemoteModelDefaultsClient { + getModelCatalog(sessionId?: string): Promise; + setModelDefaults( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ): Promise; +} + +/** Which operation failed, so a retry can offer the matching one. */ +export type RemoteModelDefaultsFailure = '' | 'load' | 'write'; + +/** + * Everything that has to hold before the device page is answerable: the host + * advertises `device_model_defaults_v1`, the link is up, and the desktop it + * points at is the one the values belong to. + */ +export interface RemoteModelDefaultsGate { + supported: boolean; + connected: boolean; + deviceId: string; +} + +/** Everything the device model settings pages render. */ +export interface RemoteModelDefaultsState { + /** The gate allows a read and a write. */ + available: boolean; + /** A device catalog read is in flight. */ + loading: boolean; + /** A write is in flight; the sheet is locked while it is true. */ + busy: boolean; + /** Device catalog: models with their `capabilities`, in host order. */ + catalog: RemoteModelCatalog; + /** Current values. Only ever replaced by a read, never by a write's intent. */ + roles: RemoteModelDefaultsRoles; + /** Raw failure text; empty when the last operation succeeded. */ + error: string; + /** Which operation produced `error`. */ + failure: RemoteModelDefaultsFailure; + /** Transient i18n status line: reading, saving, updated. */ + statusText: string; + /** True once a read has applied values for the current gate. */ + loaded: boolean; +} + +export interface RemoteModelDefaultsCallbacks { + onStateChanged: (state: RemoteModelDefaultsState) => void; +} + +/** + * Device model role defaults for the connected desktop. + * + * Reads and writes go to the desktop's own catalog, so this controller never + * guesses: a write is followed by an explicit re-read and the values on screen + * change only when that read returns. The host repairs an impossible selection + * while persisting it, and showing the intent instead of the result would + * report a configuration that was never stored. + */ +export class RemoteModelDefaultsController { + /** The reason an older desktop cannot serve the device model settings page. */ + private static readonly UNSUPPORTED_REASON_KEY: string = 'settings.model.unsupported'; + + private readonly client: RemoteModelDefaultsClient; + private readonly callbacks: RemoteModelDefaultsCallbacks; + private state: RemoteModelDefaultsState = RemoteModelDefaultsController.unavailableState(); + private gate: RemoteModelDefaultsGate = RemoteModelDefaultsController.closedGate(); + /** + * Bumped when the gate changes or the controller is reset. A result that + * lands after that belongs to a desktop the sheet no longer shows. + */ + private generation: number = 0; + + constructor(client: RemoteModelDefaultsClient, callbacks: RemoteModelDefaultsCallbacks) { + this.client = client; + this.callbacks = callbacks; + } + + getState(): RemoteModelDefaultsState { + return this.state; + } + + /** + * Re-gates the page. Any change of capability, link or desktop drops what is + * on screen (values from one desktop must never render against another) and + * returns true, so the caller knows it has to read again. + */ + setGate(gate: RemoteModelDefaultsGate): boolean { + if (RemoteModelDefaultsController.sameGate(this.gate, gate)) { + return false; + } + this.generation += 1; + this.gate = gate; + const available = gate.supported && gate.connected; + const next: RemoteModelDefaultsState = RemoteModelDefaultsController.unavailableState(); + next.available = available; + if (available) { + next.statusText = RemoteI18n.t('status.loadingModelDefaults'); + } else { + next.error = gate.supported ? + RemoteI18n.t('status.remoteUnavailable') : + RemoteI18n.t(RemoteModelDefaultsController.UNSUPPORTED_REASON_KEY); + } + this.publish(next); + return true; + } + + /** + * Reads the desktop catalog and the defaults that belong to it. + * + * Skips while a read or a write is already running so opening the page during + * a reconnect cannot queue a second read behind the first. + */ + async load(): Promise { + if (!this.state.available || this.state.loading || this.state.busy) { + return; + } + const generation = this.generation; + this.publish(RemoteModelDefaultsController.workingState(this.state, true, false, + RemoteI18n.t('status.loadingModelDefaults'))); + try { + const catalog = await this.readCatalog(generation); + if (!catalog) { + return; + } + this.publish(RemoteModelDefaultsController.readState(this.state, catalog)); + } catch (err) { + if (!this.isCurrent(generation)) { + return; + } + this.publish(RemoteModelDefaultsController.failedState(this.state, err, 'load', + RemoteI18n.t('status.modelDefaultsLoadFailed'))); + } + } + + /** + * Writes one role by sending the whole object, then reads the result back. + * + * Selecting the value a role already has writes nothing: the host reconciles + * on every write, and there is no intent to persist. + */ + async applyRoleSelection(role: RemoteModelDefaultsRole, modelId: string | null): Promise { + if (!this.state.available || this.state.busy || this.state.loading) { + return; + } + const nextRoles = RemoteModelDefaultsController.rolesWithSelection(this.state.roles, role, modelId); + if (RemoteModelDefaultsController.sameRoles(nextRoles, this.state.roles)) { + return; + } + const generation = this.generation; + this.publish(RemoteModelDefaultsController.workingState(this.state, false, true, + RemoteI18n.t('status.updatingModelDefaults'))); + try { + await this.client.setModelDefaults( + nextRoles.primary, + nextRoles.fast, + nextRoles.image_understanding + ); + if (!this.isCurrent(generation)) { + return; + } + // The acknowledgement says what was written, not what is now stored: the + // desktop may have repaired the selection, so the read decides. + try { + const catalog = await this.readCatalog(generation); + if (!catalog) { + return; + } + const readState = RemoteModelDefaultsController.readState(this.state, catalog); + readState.busy = true; + readState.statusText = RemoteI18n.t('status.modelDefaultsUpdated'); + this.publish(readState); + } catch (err) { + if (!this.isCurrent(generation)) { + return; + } + const failed = RemoteModelDefaultsController.failedState(this.state, err, 'load', + RemoteI18n.t('status.modelDefaultsLoadFailed')); + failed.busy = true; + this.publish(failed); + } + } catch (err) { + if (!this.isCurrent(generation)) { + return; + } + this.publish(RemoteModelDefaultsController.failedState(this.state, err, 'write', + RemoteI18n.t('status.modelDefaultsUpdateFailed'))); + } finally { + if (this.isCurrent(generation)) { + const done = RemoteModelDefaultsController.copyState(this.state); + done.busy = false; + this.publish(done); + } + } + } + + /** Clears the page while it is closed. */ + reset(): void { + this.generation += 1; + this.gate = RemoteModelDefaultsController.closedGate(); + this.publish(RemoteModelDefaultsController.unavailableState()); + } + + /** The i18n key of the reason an old desktop cannot serve this page. */ + static unsupportedReasonKey(): string { + return RemoteModelDefaultsController.UNSUPPORTED_REASON_KEY; + } + + /** The values for one role, mapped from the wire shape (`undefined` = null). */ + static rolesFromDefaults(defaults: RemoteDefaultModels): RemoteModelDefaultsRoles { + return { + primary: RemoteModelDefaultsController.slotValue(defaults.primary), + fast: RemoteModelDefaultsController.slotValue(defaults.fast), + image_understanding: RemoteModelDefaultsController.slotValue(defaults.image_understanding) + }; + } + + private async readCatalog(generation: number): Promise { + const catalog = await this.client.getModelCatalog(); + return this.isCurrent(generation) ? catalog : undefined; + } + + private isCurrent(generation: number): boolean { + return generation === this.generation; + } + + private publish(state: RemoteModelDefaultsState): void { + this.state = state; + this.callbacks.onStateChanged(state); + } + + private static slotValue(value: string | null | undefined): string | null { + return value === undefined || value === null || value.length === 0 ? null : value; + } + + private static rolesWithSelection( + roles: RemoteModelDefaultsRoles, + role: RemoteModelDefaultsRole, + modelId: string | null + ): RemoteModelDefaultsRoles { + if (role === 'primary') { + return { primary: modelId, fast: roles.fast, image_understanding: roles.image_understanding }; + } + if (role === 'fast') { + return { primary: roles.primary, fast: modelId, image_understanding: roles.image_understanding }; + } + if (role === 'image_understanding') { + return { primary: roles.primary, fast: roles.fast, image_understanding: modelId }; + } + return RemoteModelDefaultsController.copyRoles(roles); + } + + private static sameRoles(first: RemoteModelDefaultsRoles, second: RemoteModelDefaultsRoles): boolean { + return first.primary === second.primary && + first.fast === second.fast && + first.image_understanding === second.image_understanding; + } + + private static sameGate(first: RemoteModelDefaultsGate, second: RemoteModelDefaultsGate): boolean { + return first.supported === second.supported && + first.connected === second.connected && + first.deviceId === second.deviceId; + } + + private static copyRoles(roles: RemoteModelDefaultsRoles): RemoteModelDefaultsRoles { + return { + primary: roles.primary, + fast: roles.fast, + image_understanding: roles.image_understanding + }; + } + + private static copyState(state: RemoteModelDefaultsState): RemoteModelDefaultsState { + return { + available: state.available, + loading: state.loading, + busy: state.busy, + catalog: state.catalog, + roles: RemoteModelDefaultsController.copyRoles(state.roles), + error: state.error, + failure: state.failure, + statusText: state.statusText, + loaded: state.loaded + }; + } + + private static workingState( + state: RemoteModelDefaultsState, + loading: boolean, + busy: boolean, + statusText: string + ): RemoteModelDefaultsState { + const next = RemoteModelDefaultsController.copyState(state); + next.loading = loading; + next.busy = busy; + next.error = ''; + next.failure = ''; + next.statusText = statusText; + return next; + } + + /** Applies a read: the only path that may change the values on screen. */ + private static readState(state: RemoteModelDefaultsState, catalog: RemoteModelCatalog): RemoteModelDefaultsState { + const next = RemoteModelDefaultsController.copyState(state); + next.catalog = catalog; + next.roles = RemoteModelDefaultsController.rolesFromDefaults(catalog.default_models || {}); + next.loading = false; + next.error = ''; + next.failure = ''; + next.statusText = ''; + next.loaded = true; + return next; + } + + private static failedState( + state: RemoteModelDefaultsState, + err: Object, + failure: RemoteModelDefaultsFailure, + fallbackText: string + ): RemoteModelDefaultsState { + const next = RemoteModelDefaultsController.copyState(state); + // The host's own rejection (an unknown model id, for example) is kept as it + // arrived: replacing it with a generic line would hide what to fix. + next.error = err instanceof Error && err.message.length > 0 ? err.message : fallbackText; + next.failure = failure; + next.loading = false; + next.statusText = ''; + return next; + } + + private static unavailableState(): RemoteModelDefaultsState { + return { + available: false, + loading: false, + busy: false, + catalog: RemoteUiState.emptyModelCatalog(), + roles: { primary: null, fast: null, image_understanding: null }, + error: '', + failure: '', + statusText: '', + loaded: false + }; + } + + private static closedGate(): RemoteModelDefaultsGate { + return { supported: false, connected: false, deviceId: '' }; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets index 880ac865d1..8b847966c8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets @@ -2,7 +2,7 @@ import { WorkspaceCatalog, projectWorkspaceCatalog } from './WorkspaceCatalog'; import { RuntimeFileLocation } from '../model/RemoteModels'; import { HostSessionCallbacks, HostSessionStream, REMOTE_CAPABILITY_HOST_STREAM_V1 } from './HostSessionStream'; import { SavedRuntimeConnection, SavedRuntimeConnectionsResponse } from '../model/RemoteModels'; -import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResult, ModelCatalogResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileChunkResult, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, RemoteWorkspaceIdentity, REMOTE_CAPABILITY_WORKSPACE_ID_REFERENCES_V1, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionRolledBackResponse, SessionRolledBackResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, SteerTurnResponse, SteerTurnResult, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; +import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResult, ModelCatalogResponse, ModelDefaultsUpdatedResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileChunkResult, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteDefaultModels, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, RemoteWorkspaceIdentity, REMOTE_CAPABILITY_WORKSPACE_ID_REFERENCES_V1, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionRolledBackResponse, SessionRolledBackResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, SteerTurnResponse, SteerTurnResult, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; import { Encoding } from './Encoding'; import { CloudAccountClient, CloudAccountRequestError, CloudAccountSession } from './CloudAccountClient'; import { RemoteI18n } from '../i18n/RemoteI18n'; @@ -16,6 +16,7 @@ import { RemoteChatCommandClient } from './RemoteChatCommandController'; import { RemoteFileDownloadClient } from './RemoteFileDownloadController'; import { RemoteLogger } from './RemoteLogger'; import { RemoteModelClient } from './RemoteModelController'; +import { RemoteModelDefaultsClient } from './RemoteModelDefaultsController'; import { RemoteResponseMapper } from './RemoteResponseMapper'; import { stampRemoteSessionsWorkspace } from './RemoteSessionIdentity'; import { TranscriptIntegrityPolicy } from './TranscriptIntegrityPolicy'; @@ -38,7 +39,7 @@ const ACCOUNT_HANDSHAKE_TIMEOUT_MS: number = 15000; interface PermissionReplyArgs { request: PermissionReplyRequest; } interface PermissionReplyRequest { requestId: string; reply: string; feedback?: string; updatedInput?: Object; } -export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFileDownloadClient, RemoteModelClient, RemoteSessionClient, RemoteToolActionClient { +export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFileDownloadClient, RemoteModelClient, RemoteModelDefaultsClient, RemoteSessionClient, RemoteToolActionClient { private transport?: RemoteCommandTransport; private transportGeneration: number = 0; private workspace?: WorkspaceInfo; @@ -447,6 +448,58 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile return response.model_id || modelId; } + /** + * Writes the desktop's model role defaults and answers with what is stored. + * + * All six slots come back because the desktop reconciles on every write: a + * model that was removed can leave a role pointing at another model, and the + * caller must show the result rather than the request. A host that only + * acknowledges the command without echoing the slots leaves the answer to an + * authoritative read, exactly like a legacy permission-mode acknowledgement. + */ + async setModelDefaults( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ): Promise { + const response = await this.send( + RemoteCommandFactory.setModelDefaults(primary, fast, imageUnderstanding) + ); + const defaults = response.default_models; + if (!defaults) { + const catalog = await this.getModelCatalog(); + return RemoteSessionManager.modelDefaults(catalog.default_models); + } + return RemoteSessionManager.modelDefaults(defaults); + } + + /** + * Normalizes an echoed defaults projection into all six slots. + * + * A slot that is neither a string nor null cannot be a stored selection, so + * it is refused instead of being shown as a configured model. + */ + private static modelDefaults(defaults: RemoteDefaultModels): RemoteDefaultModels { + return { + primary: RemoteSessionManager.modelDefaultsSlot(defaults.primary), + fast: RemoteSessionManager.modelDefaultsSlot(defaults.fast), + search: RemoteSessionManager.modelDefaultsSlot(defaults.search), + image_understanding: RemoteSessionManager.modelDefaultsSlot(defaults.image_understanding), + image_generation: RemoteSessionManager.modelDefaultsSlot(defaults.image_generation), + speech_recognition: RemoteSessionManager.modelDefaultsSlot(defaults.speech_recognition) + }; + } + + private static modelDefaultsSlot(value: string | null | undefined): string | null { + if (value === undefined || value === null) { + return null; + } + if (typeof value !== 'string') { + throw new Error('Model defaults unavailable'); + } + return value; + } + async pollSession( sessionId: string, sinceVersion: number, diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json b/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json index 3ac6bed215..d37f50b8a3 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json @@ -568,6 +568,62 @@ "name": "settings_model_default", "value": "Default model" }, + { + "name": "settings_model_settingsTitle", + "value": "Model settings" + }, + { + "name": "settings_model_upgradeBadge", + "value": "Update" + }, + { + "name": "settings_model_unsupported", + "value": "The OpenBitFun app on this device is too old to configure model defaults. Update OpenBitFun on that device to continue." + }, + { + "name": "settings_model_scopeNewSessions", + "value": "These defaults apply to new sessions on this device; sessions already running are unaffected." + }, + { + "name": "settings_model_scopeFastFollowsPrimary", + "value": "When the fast model is not set, it follows the primary model." + }, + { + "name": "settings_model_scopeImageUnderstanding", + "value": "The multimodal model is used for image understanding only and does not replace the primary model." + }, + { + "name": "settings_model_capabilityImageUnderstanding", + "value": "Image understanding" + }, + { + "name": "settings_model_emptyTitle", + "value": "This device has no models configured yet" + }, + { + "name": "settings_model_emptyText", + "value": "Add models in OpenBitFun on that device, then come back to set role defaults." + }, + { + "name": "settings_model_filterTextChat", + "value": "Only enabled models that support text chat (text_chat) are listed · Applies to new sessions on %1$s" + }, + { + "name": "settings_model_filterImageUnderstanding", + "value": "Only models that support image understanding (image_understanding) are listed · Applies to new sessions on %1$s" + }, + { + "name": "settings_model_updatedResult", + "value": "Updated to %1$s · Re-read the current values on this device" + }, + { + "name": "settings_model_imageUnderstandingUnset", + "value": "Without a multimodal model, image understanding is unavailable" + }, + { + "name": "settings_model_pickEmpty", + "value": "No models on this device match this role yet" + }, { "name": "settings_devices_section", "value": "Devices" @@ -1888,6 +1944,14 @@ "name": "chat_modelRoleFast", "value": "Fast" }, + { + "name": "chat_modelImage", + "value": "Multimodal model" + }, + { + "name": "chat_modelFollowPrimary", + "value": "Follow the primary model" + }, { "name": "chat_loadOlder", "value": "Load older messages" @@ -2436,6 +2500,26 @@ "name": "status_modelSwitched", "value": "Model switched" }, + { + "name": "status_loadingModelDefaults", + "value": "Reading the device's model defaults" + }, + { + "name": "status_updatingModelDefaults", + "value": "Saving the device's model defaults" + }, + { + "name": "status_modelDefaultsUpdated", + "value": "Updated" + }, + { + "name": "status_modelDefaultsLoadFailed", + "value": "Could not read the device's model defaults. Try again." + }, + { + "name": "status_modelDefaultsUpdateFailed", + "value": "Could not save the device's model defaults. Try again." + }, { "name": "status_loadOlderMessages", "value": "Load older messages" diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json b/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json index 6a8ba96e08..0298b117ab 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json @@ -568,6 +568,62 @@ "name": "settings_model_default", "value": "默认模型" }, + { + "name": "settings_model_settingsTitle", + "value": "模型设置" + }, + { + "name": "settings_model_upgradeBadge", + "value": "升级" + }, + { + "name": "settings_model_unsupported", + "value": "该设备上的 OpenBitFun 版本过旧,不支持配置模型默认值。请升级该设备上的 OpenBitFun 后继续。" + }, + { + "name": "settings_model_scopeNewSessions", + "value": "这些默认值作用于该设备上的新会话,进行中的会话不受影响。" + }, + { + "name": "settings_model_scopeFastFollowsPrimary", + "value": "快速模型未配置时,跟随主力模型。" + }, + { + "name": "settings_model_scopeImageUnderstanding", + "value": "多模态模型只用于图片理解,不替换主力模型。" + }, + { + "name": "settings_model_capabilityImageUnderstanding", + "value": "图片理解" + }, + { + "name": "settings_model_emptyTitle", + "value": "该设备尚未配置任何模型" + }, + { + "name": "settings_model_emptyText", + "value": "请先在该设备的 OpenBitFun 里添加模型,再回到这里设置角色默认值。" + }, + { + "name": "settings_model_filterTextChat", + "value": "仅显示已启用且支持文本对话(text_chat)的模型 · 作用于 %1$s 上的新会话" + }, + { + "name": "settings_model_filterImageUnderstanding", + "value": "仅显示支持图片理解(image_understanding)的模型 · 作用于 %1$s 上的新会话" + }, + { + "name": "settings_model_updatedResult", + "value": "已更新为 %1$s · 已重新读取该设备上的当前值" + }, + { + "name": "settings_model_imageUnderstandingUnset", + "value": "未指定多模态模型时,图片理解相关能力不可用" + }, + { + "name": "settings_model_pickEmpty", + "value": "该设备暂无可用于该角色的模型" + }, { "name": "settings_devices_section", "value": "设备" @@ -1888,6 +1944,14 @@ "name": "chat_modelRoleFast", "value": "快速" }, + { + "name": "chat_modelImage", + "value": "多模态模型" + }, + { + "name": "chat_modelFollowPrimary", + "value": "跟随主力" + }, { "name": "chat_loadOlder", "value": "加载更早消息" @@ -2436,6 +2500,26 @@ "name": "status_modelSwitched", "value": "模型已切换" }, + { + "name": "status_loadingModelDefaults", + "value": "正在读取模型默认值" + }, + { + "name": "status_updatingModelDefaults", + "value": "正在保存模型默认值" + }, + { + "name": "status_modelDefaultsUpdated", + "value": "已更新" + }, + { + "name": "status_modelDefaultsLoadFailed", + "value": "读取模型默认值失败,请重试。" + }, + { + "name": "status_modelDefaultsUpdateFailed", + "value": "保存模型默认值失败,请重试。" + }, { "name": "status_loadOlderMessages", "value": "加载更早消息" diff --git a/src/apps/mobile/harmonyos/tools/tests/device-model-defaults.test.cjs b/src/apps/mobile/harmonyos/tools/tests/device-model-defaults.test.cjs new file mode 100644 index 0000000000..8c07295f52 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/device-model-defaults.test.cjs @@ -0,0 +1,444 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +const root = path.resolve(__dirname, '../..'); +const etsRoot = path.join(root, 'entry/src/main/ets'); + +// Run the production ArkTS classes with only platform/decorator-free mocks. The +// controller, the wire sender and the entry policy are plain classes on purpose: +// the rules they encode are what this suite pins. +function load(relativePath, resolve) { + const source = fs.readFileSync(path.join(etsRoot, relativePath), 'utf8'); + const js = ts.transpileModule(source, { + compilerOptions: {target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS} + }).outputText; + const exported = {}; + new Function('require', 'exports', js)(name => (resolve ? resolve(name) : undefined) || {}, exported); + return exported; +} + +const factory = load('services/RemoteCommandFactory.ets'); +const {RemoteCommandFactory} = factory; +const {RemoteSessionManager} = load('services/RemoteSessionManager.ets', name => + name.endsWith('RemoteCommandFactory') ? factory : {}); + +function controllerModule() { + return load('services/RemoteModelDefaultsController.ets', name => + name.endsWith('RemoteI18n') ? {RemoteI18n: {t: key => key}} : + name.endsWith('RemoteUiState') ? {RemoteUiState: {emptyModelCatalog: () => emptyCatalog()}} : {}); +} + +const emptyCatalog = () => ({version: 0, models: [], default_models: {}}); + +function catalogNames(locale) { + const entries = JSON.parse( + fs.readFileSync(path.join(root, `entry/src/main/resources/${locale}/element/string.json`), 'utf8') + ).string; + return new Set(entries.map(entry => entry.name)); +} + +// Resource names cannot carry dots, so a code key maps to its underscore name. +test('every key the entry and the controller render exists in both catalogs', () => { + const english = catalogNames('en_US'); + const chinese = catalogNames('zh_CN'); + const keys = [ + 'settings.model.settingsTitle', + 'settings.model.upgradeBadge', + 'settings.model.unsupported', + 'settings.model.section', + 'settings.model.default', + 'status.loadingModelDefaults', + 'status.updatingModelDefaults', + 'status.modelDefaultsUpdated', + 'status.modelDefaultsLoadFailed', + 'status.modelDefaultsUpdateFailed', + 'status.remoteUnavailable' + ]; + for (const key of keys) { + const name = key.replace(/\./g, '_'); + assert.ok(english.has(name), `${key} is missing from en_US (${name})`); + assert.ok(chinese.has(name), `${key} is missing from zh_CN (${name})`); + } +}); +const catalog = (defaults = {}, extra = {}) => ({ + version: 3, + models: [ + {id: 'm-primary', name: 'Primary', provider: 'openai', base_url: '', model_name: 'm-primary', enabled: true, capabilities: []}, + { + id: 'm-vision', name: 'Vision', provider: 'openai', base_url: '', model_name: 'm-vision', + enabled: true, capabilities: ['text_chat', 'image_understanding'] + } + ], + default_models: defaults, + ...extra +}); + +const flush = () => new Promise(resolve => setImmediate(resolve)); + +function deferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return {promise, resolve, reject}; +} + +function controllerWith(client) { + const {RemoteModelDefaultsController} = controllerModule(); + const states = []; + const controller = new RemoteModelDefaultsController(client, { + onStateChanged: state => { states.push(state); } + }); + return {controller, states, state: () => controller.getState()}; +} + +const gate = (supported, connected, deviceId) => ({supported, connected, deviceId}); + +test('set_model_defaults sends every slot in one object and null is a value', () => { + assert.deepEqual(RemoteCommandFactory.setModelDefaults('m-primary', 'm-fast', null), { + cmd: 'set_model_defaults', + primary: 'm-primary', + fast: 'm-fast', + image_understanding: null + }); + const cleared = RemoteCommandFactory.setModelDefaults(null, null, null); + assert.deepEqual(cleared, { + cmd: 'set_model_defaults', + primary: null, + fast: null, + image_understanding: null + }); + // An absent key would be ambiguous between "clear" and "leave unchanged", so + // the whole object is always on the wire. + assert.deepEqual(Object.keys(cleared).sort(), ['cmd', 'fast', 'image_understanding', 'primary']); +}); + +test('the acknowledgement is parsed as all six slots, nulls included', async () => { + const manager = Object.create(RemoteSessionManager.prototype); + manager.send = async () => ({ + resp: 'model_defaults_updated', + default_models: { + primary: 'm-primary', + fast: null, + search: 'm-search', + image_understanding: 'm-vision', + image_generation: 'm-image', + speech_recognition: 'm-speech' + } + }); + assert.deepEqual(await manager.setModelDefaults('m-primary', null, 'm-vision'), { + primary: 'm-primary', + fast: null, + search: 'm-search', + image_understanding: 'm-vision', + image_generation: 'm-image', + speech_recognition: 'm-speech' + }); +}); + +test('an acknowledgement without slots is followed by an authoritative device read', async () => { + const manager = Object.create(RemoteSessionManager.prototype); + const commands = []; + manager.send = async command => { + commands.push(command); + if (command.cmd === 'set_model_defaults') { + return {resp: 'model_defaults_updated'}; + } + return {resp: 'ok', catalog: catalog({primary: 'm-primary', fast: null})}; + }; + const defaults = await manager.setModelDefaults('m-primary', null, null); + assert.deepEqual(commands.map(command => command.cmd), ['set_model_defaults', 'get_model_catalog']); + // Device scope: the defaults belong to the desktop, not to an open session. + assert.deepEqual(commands[1], {cmd: 'get_model_catalog'}); + assert.deepEqual(defaults, { + primary: 'm-primary', + fast: null, + search: null, + image_understanding: null, + image_generation: null, + speech_recognition: null + }); +}); + +test('a slot that is not a model id is refused instead of shown as configured', async () => { + const manager = Object.create(RemoteSessionManager.prototype); + manager.send = async () => ({resp: 'model_defaults_updated', default_models: {primary: 42}}); + await assert.rejects(manager.setModelDefaults('m-primary', null, null), /Model defaults unavailable/); +}); + +test('the entry belongs to the connected control target and to no other row', () => { + const {DeviceModelSettingsEntryPolicy: entry} = load( + 'pages/policy/DeviceModelSettingsEntryPolicy.ets', + name => name.endsWith('RemoteModels') ? + {REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1: 'device_model_defaults_v1'} : {} + ); + const shows = (online, deviceId, targetId, connectionState) => + entry.showsEntry(online, deviceId, 'phone-1', targetId, connectionState); + + assert.equal(shows(true, 'desk-1', 'desk-1', 'connected'), true); + // Offline rows carry no entry at all. + assert.equal(shows(false, 'desk-1', 'desk-1', 'connected'), false); + // An online desktop this phone is not driving cannot answer a device write. + assert.equal(shows(true, 'desk-2', 'desk-1', 'connected'), false); + assert.equal(shows(true, 'desk-1', 'desk-1', 'reconnecting'), false); + assert.equal(shows(true, 'desk-1', 'desk-1', 'idle'), false); + // Never on the phone's own row, and never without an identity. + assert.equal(shows(true, 'phone-1', 'phone-1', 'connected'), false); + assert.equal(shows(true, '', '', 'connected'), false); + + assert.equal(entry.supportsDeviceModelDefaults(['device_model_defaults_v1', 'host_stream_v1']), true); + assert.equal(entry.supportsDeviceModelDefaults(['workspace_id_references_v1']), false); + assert.equal(entry.supportsDeviceModelDefaults([]), false); + assert.equal(entry.supportsDeviceModelDefaults(undefined), false); +}); + +test('a missing capability greys the entry, badges it and explains the tap', () => { + const {DeviceModelSettingsEntryPolicy: entry} = load( + 'pages/policy/DeviceModelSettingsEntryPolicy.ets', + name => name.endsWith('RemoteModels') ? + {REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1: 'device_model_defaults_v1'} : {} + ); + const shown = entry.showsEntry(true, 'desk-1', 'phone-1', 'desk-1', 'connected'); + + assert.equal(entry.entryEnabled(true), true); + assert.equal(entry.entryEnabled(false), false); + assert.equal(entry.showsUpgradeBadge(shown, true), false); + assert.equal(entry.showsUpgradeBadge(shown, false), true); + assert.equal(entry.degradeReasonKey(shown, true), ''); + assert.equal(entry.degradeReasonKey(shown, false), 'settings.model.unsupported'); + // No entry means no reason band: the row cannot be tapped in the first place. + assert.equal(entry.showsUpgradeBadge(false, false), false); + assert.equal(entry.degradeReasonKey(false, false), ''); +}); + +test('the controller reads the device catalog without a session id', async () => { + const calls = []; + const {controller, state} = controllerWith({ + getModelCatalog: async (...args) => { + calls.push(args); + return catalog({primary: 'm-primary', fast: null, image_understanding: 'm-vision'}); + }, + setModelDefaults: async () => { + throw new Error('write is not expected here'); + } + }); + + assert.equal(controller.setGate(gate(true, true, 'desk-1')), true); + await controller.load(); + + assert.deepEqual(calls, [[]]); + assert.equal(state().available, true); + assert.equal(state().loaded, true); + assert.equal(state().loading, false); + assert.equal(state().statusText, ''); + assert.deepEqual(state().roles, {primary: 'm-primary', fast: null, image_understanding: 'm-vision'}); + assert.equal(state().catalog.models.length, 2); +}); + +test('an old desktop is explained, not read', async () => { + let reads = 0; + const {controller, state} = controllerWith({ + getModelCatalog: async () => { reads += 1; return catalog(); }, + setModelDefaults: async () => { throw new Error('write is not expected here'); } + }); + + assert.equal(controller.setGate(gate(false, true, 'desk-1')), true); + await controller.load(); + + assert.equal(reads, 0); + assert.equal(state().available, false); + assert.equal(state().error, 'settings.model.unsupported'); + assert.equal(state().failure, ''); + assert.deepEqual(state().roles, {primary: null, fast: null, image_understanding: null}); +}); + +test('a dropped link is unavailable and gates both actions', async () => { + let reads = 0; + let writes = 0; + const {controller, state} = controllerWith({ + getModelCatalog: async () => { reads += 1; return catalog({primary: 'm-primary'}); }, + setModelDefaults: async () => { writes += 1; return {}; } + }); + + assert.equal(controller.setGate(gate(true, false, 'desk-1')), true); + await controller.load(); + await controller.applyRoleSelection('fast', 'm-vision'); + + assert.equal(reads, 0); + assert.equal(writes, 0); + assert.equal(state().available, false); + assert.equal(state().error, 'status.remoteUnavailable'); +}); + +test('a write is locked while it runs and sends the whole object', async () => { + const write = deferred(); + const writeCalls = []; + let stored = {primary: 'm-primary', fast: null, image_understanding: null}; + const {controller, state} = controllerWith({ + getModelCatalog: async () => catalog(stored), + setModelDefaults: async (...args) => { + writeCalls.push(args); + stored = {primary: args[0], fast: args[1], image_understanding: args[2]}; + return write.promise; + } + }); + + controller.setGate(gate(true, true, 'desk-1')); + await controller.load(); + + const pending = controller.applyRoleSelection('fast', 'm-vision'); + assert.equal(state().busy, true); + assert.equal(state().statusText, 'status.updatingModelDefaults'); + assert.deepEqual(writeCalls, [['m-primary', 'm-vision', null]]); + // The three slots travel together; only the selected one changed. + await controller.applyRoleSelection('primary', 'm-vision'); + await controller.load(); + assert.deepEqual(writeCalls, [['m-primary', 'm-vision', null]]); + + write.resolve({primary: 'm-primary', fast: 'm-vision', image_understanding: null}); + await pending; + assert.equal(state().busy, false); + assert.equal(state().statusText, 'status.modelDefaultsUpdated'); + assert.equal(state().roles.fast, 'm-vision'); +}); + +test('values change only after the read-back, never optimistically', async () => { + const write = deferred(); + const read = deferred(); + let reads = 0; + const {controller, state} = controllerWith({ + getModelCatalog: async () => { + reads += 1; + // The first read is the page open; the second is the post-write re-read. + return reads === 1 ? + catalog({primary: 'm-primary', fast: null, image_understanding: null}) : read.promise; + }, + setModelDefaults: async () => write.promise + }); + + controller.setGate(gate(true, true, 'desk-1')); + await controller.load(); + assert.equal(state().roles.primary, 'm-primary'); + + const pending = controller.applyRoleSelection('primary', 'm-vision'); + // In flight: the request is out, nothing on screen moved. + assert.equal(state().busy, true); + assert.equal(state().roles.primary, 'm-primary'); + + write.resolve({}); + await flush(); + // Acknowledged: still nothing on screen, because the desktop reconciles and + // the read has not answered yet. + assert.equal(reads, 2); + assert.equal(state().roles.primary, 'm-primary'); + assert.equal(state().busy, true); + + // The desktop repaired the selection while persisting it: the read decides. + read.resolve(catalog({primary: 'm-reconciled', fast: 'm-vision', image_understanding: null})); + await pending; + assert.deepEqual(state().roles, {primary: 'm-reconciled', fast: 'm-vision', image_understanding: null}); + assert.equal(state().busy, false); + assert.equal(state().statusText, 'status.modelDefaultsUpdated'); +}); + +test('selecting the value a role already has writes nothing', async () => { + let writes = 0; + const {controller, state} = controllerWith({ + getModelCatalog: async () => catalog({primary: 'm-primary', fast: 'm-vision'}), + setModelDefaults: async () => { writes += 1; return {}; } + }); + + controller.setGate(gate(true, true, 'desk-1')); + await controller.load(); + await controller.applyRoleSelection('fast', 'm-vision'); + await controller.applyRoleSelection('fast', 'm-vision'); + + assert.equal(writes, 0); + assert.equal(state().busy, false); +}); + +test('an unknown model id and a transport failure stay distinct', async () => { + const rejected = controllerWith({ + getModelCatalog: async () => catalog({primary: 'm-primary'}), + setModelDefaults: async () => { throw new Error('Unknown model id: m-missing'); } + }); + rejected.controller.setGate(gate(true, true, 'desk-1')); + await rejected.controller.load(); + await rejected.controller.applyRoleSelection('primary', 'm-missing'); + assert.equal(rejected.state().error, 'Unknown model id: m-missing'); + assert.equal(rejected.state().failure, 'write'); + assert.equal(rejected.state().busy, false); + assert.equal(rejected.state().roles.primary, 'm-primary'); + + const offline = controllerWith({ + getModelCatalog: async () => { throw new Error('Remote transport is not connected.'); }, + setModelDefaults: async () => { throw new Error('write is not expected here'); } + }); + offline.controller.setGate(gate(true, true, 'desk-1')); + await offline.controller.load(); + assert.equal(offline.state().error, 'Remote transport is not connected.'); + assert.equal(offline.state().failure, 'load'); + assert.equal(offline.state().loading, false); +}); + +test('a failed read-back after an accepted write is a read failure', async () => { + let reads = 0; + const {controller, state} = controllerWith({ + getModelCatalog: async () => { + reads += 1; + if (reads === 1) { + return catalog({primary: 'm-primary'}); + } + throw new Error('Remote transport is not connected.'); + }, + setModelDefaults: async () => ({primary: 'm-vision'}) + }); + + controller.setGate(gate(true, true, 'desk-1')); + await controller.load(); + await controller.applyRoleSelection('primary', 'm-vision'); + + assert.equal(state().failure, 'load'); + assert.equal(state().error, 'Remote transport is not connected.'); + assert.equal(state().busy, false); + // The write was accepted but unconfirmed, so the old value stays on screen. + assert.equal(state().roles.primary, 'm-primary'); +}); + +test('a result that lands after a gate change is dropped', async () => { + const read = deferred(); + const {controller, state} = controllerWith({ + getModelCatalog: async () => read.promise, + setModelDefaults: async () => ({}) + }); + + controller.setGate(gate(true, true, 'desk-1')); + const pending = controller.load(); + controller.setGate(gate(true, true, 'desk-2')); + read.resolve(catalog({primary: 'm-primary'})); + await pending; + + // The values belong to the desktop the sheet has already left. + assert.deepEqual(state().roles, {primary: null, fast: null, image_understanding: null}); + assert.equal(state().loaded, false); + assert.equal(state().loading, false); +}); + +test('reset clears the page and a repeated gate change reports no reset', async () => { + const {controller, state} = controllerWith({ + getModelCatalog: async () => catalog({primary: 'm-primary'}), + setModelDefaults: async () => ({}) + }); + + assert.equal(controller.setGate(gate(true, true, 'desk-1')), true); + assert.equal(controller.setGate(gate(true, true, 'desk-1')), false); + await controller.load(); + assert.equal(state().loaded, true); + + controller.reset(); + assert.equal(state().available, false); + assert.equal(state().loaded, false); + assert.deepEqual(state().roles, {primary: null, fast: null, image_understanding: null}); +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/model-role-pages.test.cjs b/src/apps/mobile/harmonyos/tools/tests/model-role-pages.test.cjs new file mode 100644 index 0000000000..a8d1617398 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/model-role-pages.test.cjs @@ -0,0 +1,726 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +const root = path.resolve(__dirname, '../..'); +const etsRoot = path.join(root, 'entry/src/main/ets'); + +// The two device-model pages are one workflow, and most of what they promise is +// decided before ArkUI sees anything: which models a role may be set to, what a +// role row says, when a write is allowed, and what the sheet reports afterwards. +// Those rules are plain classes and pure functions, so they are run here for +// real. The decorators are the platform's binding rather than part of the rules, +// so they come off before transpiling. +function load(relativePath, resolve) { + const source = fs.readFileSync(path.join(etsRoot, relativePath), 'utf8') + .replace(/@ObservedV2\s*/g, '').replace(/@Trace\s*/g, ''); + const js = ts.transpileModule(source, { + compilerOptions: {target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS} + }).outputText; + const exported = {}; + new Function('require', 'exports', js)(name => (resolve ? resolve(name) : undefined) || {}, exported); + return exported; +} + +function source(relativePath) { + return fs.readFileSync(path.join(root, relativePath), 'utf8'); +} + +const emptyCatalog = () => ({version: 0, models: [], default_models: {}}); + +function controllerModule() { + return load('services/RemoteModelDefaultsController.ets', name => + name.endsWith('RemoteI18n') ? {RemoteI18n: {t: key => key}} : + name.endsWith('RemoteUiState') ? {RemoteUiState: {emptyModelCatalog: () => emptyCatalog()}} : {}); +} + +function policyModule() { + return load('pages/policy/ModelRoleCandidatesPolicy.ets'); +} + +function sheetStateModule() { + const controller = controllerModule(); + const policy = policyModule(); + return load('pages/state/DeviceModelDefaultsSheetState.ets', name => + name.endsWith('RemoteModelDefaultsController') ? controller : + name.endsWith('RemoteUiState') ? {RemoteUiState: {emptyModelCatalog: () => emptyCatalog()}} : + name.endsWith('ModelRoleCandidatesPolicy') ? policy : {}); +} + +function settingsSheetStateModule() { + const models = sheetStateModule(); + return load('pages/state/SettingsSheetState.ets', name => + name.endsWith('DeviceModelDefaultsSheetState') ? models : {}); +} + +const catalog = (defaults = {}) => ({ + version: 3, + models: [ + {id: 'm-primary', name: 'Primary', provider: 'openai', base_url: '', model_name: 'm-primary', enabled: true, capabilities: ['text_chat']}, + { + id: 'm-vision', name: 'Vision', provider: 'openai', base_url: '', model_name: 'm-vision', + enabled: true, capabilities: ['text_chat', 'image_understanding'] + } + ], + default_models: defaults +}); + +const catalogOf = models => ({version: 1, models, default_models: {}}); +const ids = models => models.map(model => model.id); +const gate = (supported, connected, deviceId) => ({supported, connected, deviceId}); +const flush = () => new Promise(resolve => setImmediate(resolve)); +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); + +function deferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return {promise, resolve, reject}; +} + +/** The sheet state over one fixture desktop, with the reads and writes recorded. */ +function sheetWith(client, feedbackDurationMs = 2000) { + const {DeviceModelDefaultsSheetState} = sheetStateModule(); + const sheet = new DeviceModelDefaultsSheetState(feedbackDurationMs); + sheet.bind(client); + return sheet; +} + +// --------------------------------------------------------------------------- +// Candidates +// --------------------------------------------------------------------------- + +test('the picker offers enabled, capable models in the catalog own order', () => { + const {ModelRoleCandidatesPolicy: policy} = policyModule(); + const models = [ + {id: 'm-first', enabled: true, capabilities: ['text_chat']}, + {id: 'm-disabled', enabled: false, capabilities: ['text_chat', 'image_understanding']}, + {id: 'm-both', enabled: true, capabilities: ['text_chat', 'image_understanding']}, + {id: 'm-vision', enabled: true, capabilities: ['image_understanding']}, + {id: 'm-plain', enabled: true, capabilities: []}, + {id: 'm-uncapable', enabled: true} + ]; + const catalog = catalogOf(models); + + // A disabled model is not a candidate at all, and neither is one without the + // capability the role needs: the desktop would refuse both writes. + assert.deepEqual(ids(policy.textChatCandidates(catalog)), ['m-first', 'm-both']); + assert.deepEqual(ids(policy.imageUnderstandingCandidates(catalog)), ['m-both', 'm-vision']); + // Order is the desktop's, not a local sort: this list and the desktop's own + // model list have to describe the same device. + assert.deepEqual(ids(policy.textChatCandidates(catalog)), ids(models.filter(model => + model.enabled && (model.capabilities || []).indexOf('text_chat') >= 0))); + + assert.deepEqual(ids(policy.candidatesForRole('primary', catalog)), ['m-first', 'm-both']); + assert.deepEqual(ids(policy.candidatesForRole('fast', catalog)), ['m-first', 'm-both']); + assert.deepEqual(ids(policy.candidatesForRole('image_understanding', catalog)), ['m-both', 'm-vision']); + + assert.deepEqual(policy.textChatCandidates(emptyCatalog()), []); + assert.deepEqual(policy.imageUnderstandingCandidates(emptyCatalog()), []); + assert.equal(policy.isEmptyCatalog(emptyCatalog()), true); + assert.equal(policy.isEmptyCatalog(catalog), false); +}); + +test('the pinned row is the role own "no model" value, and only the primary has none', () => { + const {ModelRoleCandidatesPolicy: policy} = policyModule(); + + // The primary model is required: a phone that cleared it would leave the + // desktop with nothing to run. + assert.equal(policy.allowsUnset('primary'), false); + assert.equal(policy.allowsUnset('fast'), true); + assert.equal(policy.allowsUnset('image_understanding'), true); + + assert.equal(policy.unsetLabelKey('fast'), 'chat.modelFollowPrimary'); + assert.equal(policy.unsetLabelKey('image_understanding'), 'settings.modelService.notConfigured'); + assert.equal(policy.unsetHintKey('fast'), 'chat.modelFastNotConfigured'); + assert.equal(policy.unsetHintKey('image_understanding'), 'settings.model.imageUnderstandingUnset'); + assert.equal(policy.unsetHintKey('primary'), ''); + + const roles = {primary: 'm-primary', fast: null, image_understanding: 'm-vision'}; + assert.equal(policy.isUnsetSelected(roles, 'fast'), true); + assert.equal(policy.isUnsetSelected(roles, 'image_understanding'), false); + // The primary can never be "unset", so its picker has no such row to check. + assert.equal(policy.isUnsetSelected(roles, 'primary'), false); + assert.equal(policy.isCandidateSelected(roles, 'image_understanding', 'm-vision'), true); + assert.equal(policy.isCandidateSelected(roles, 'image_understanding', 'm-both'), false); + assert.equal(policy.isCandidateSelected(roles, 'fast', 'm-primary'), false); + + // The capability badge explains one list only; elsewhere it would widen rows. + assert.equal(policy.showsCapabilityBadge('image_understanding'), true); + assert.equal(policy.showsCapabilityBadge('fast'), false); + assert.equal(policy.showsCapabilityBadge('primary'), false); + assert.equal(policy.roleLabelKey('image_understanding'), 'chat.modelImage'); + assert.equal(policy.filterKey('image_understanding'), 'settings.model.filterImageUnderstanding'); + assert.equal(policy.filterKey('fast'), 'settings.model.filterTextChat'); + assert.equal(policy.filterKey('primary'), 'settings.model.filterTextChat'); +}); + +// --------------------------------------------------------------------------- +// Role rows +// --------------------------------------------------------------------------- + +test('a role row states the value the device returned and the fallback it uses', () => { + const {ModelRoleCandidatesPolicy: policy} = policyModule(); + const roles = {primary: 'm-primary', fast: null, image_understanding: null}; + const fixture = catalog({primary: 'm-primary', fast: null, image_understanding: null}); + + const primary = policy.roleRow(roles, 'primary', fixture); + assert.equal(primary.modelId, 'm-primary'); + assert.equal(primary.model.id, 'm-primary'); + assert.equal(primary.unset, false); + assert.equal(primary.unsetKey, ''); + assert.equal(primary.resolved, true); + + // An empty fast slot follows the primary, and says so in those words. + const fast = policy.roleRow(roles, 'fast', fixture); + assert.equal(fast.unset, true); + assert.equal(fast.unsetKey, 'chat.modelFollowPrimary'); + assert.equal(fast.hintKey, 'chat.modelFastNotConfigured'); + assert.equal(fast.resolved, true); + + // The multimodal slot has no fallback: an empty one means the capability is + // off, not borrowed from another role. + const image = policy.roleRow(roles, 'image_understanding', fixture); + assert.equal(image.unsetKey, 'settings.modelService.notConfigured'); + assert.equal(image.hintKey, 'settings.model.imageUnderstandingUnset'); + assert.equal(image.resolved, false); + assert.equal(policy.resolvedModel(roles, 'image_understanding', fixture), undefined); + + // A value the catalog no longer lists keeps its id: the desktop holds it, and + // hiding it behind "not configured" would misreport the device. + const orphan = policy.roleRow({primary: 'm-gone'}, 'primary', fixture); + assert.equal(orphan.modelId, 'm-gone'); + assert.equal(orphan.model, undefined); + assert.equal(orphan.unset, false); + assert.equal(orphan.resolved, false); + + // A disabled model is not a resolved one either. + const disabled = catalogOf([{id: 'm-off', enabled: false, capabilities: ['text_chat']}]); + assert.equal(policy.roleRow({primary: 'm-off'}, 'primary', disabled).resolved, false); + + // With no models at all every row says unconfigured and none promises a + // fallback, because there is no primary to follow. + for (const role of ['primary', 'fast', 'image_understanding']) { + const empty = policy.roleRow(roles, role, emptyCatalog()); + assert.equal(empty.unset, true, role); + assert.equal(empty.unsetKey, 'settings.modelService.notConfigured', role); + assert.equal(empty.hintKey, '', role); + assert.equal(empty.resolved, false, role); + } +}); + +// --------------------------------------------------------------------------- +// Page state: gating, the write, the read-back, the feedback +// --------------------------------------------------------------------------- + +test('the gate is armed when the sheet opens and spent when a page opens', async () => { + let reads = 0; + const sheet = sheetWith({ + getModelCatalog: async () => { reads += 1; return catalog({primary: 'm-primary'}); }, + setModelDefaults: async () => { throw new Error('write is not expected here'); } + }); + + assert.equal(sheet.setGate(gate(true, true, 'desk-1')), true); + // Arming is not reading: a sheet opened for another setting asks nothing. + assert.equal(reads, 0); + assert.equal(sheet.state.available, true); + assert.equal(sheet.state.loaded, false); + + await sheet.ensureLoaded(); + await sheet.ensureLoaded(); + assert.equal(reads, 1); + assert.equal(sheet.state.loaded, true); + assert.equal(sheet.state.roles.primary, 'm-primary'); + + // The same gate is not a reset, so what is on screen survives a rebuild. + assert.equal(sheet.setGate(gate(true, true, 'desk-1')), false); + await sheet.ensureLoaded(); + assert.equal(reads, 1); + + // Another desktop is a different answer, so the page drops what it had. + assert.equal(sheet.setGate(gate(true, true, 'desk-2')), true); + assert.equal(sheet.state.loaded, false); + assert.equal(sheet.state.available, true); +}); + +test('an older desktop and a dropped link are stated, not read', async () => { + let reads = 0; + const sheet = sheetWith({ + getModelCatalog: async () => { reads += 1; return catalog(); }, + setModelDefaults: async () => { throw new Error('write is not expected here'); } + }); + + assert.equal(sheet.setGate(gate(false, true, 'desk-1')), true); + await sheet.ensureLoaded(); + await sheet.selectRole('primary', 'm-primary'); + assert.equal(reads, 0); + assert.equal(sheet.state.available, false); + assert.equal(sheet.state.error, 'settings.model.unsupported'); + // Nothing was written and nothing claims it was. + assert.equal(sheet.feedbackVisible, false); + + assert.equal(sheet.setGate(gate(true, false, 'desk-1')), true); + assert.equal(sheet.state.available, false); + assert.equal(sheet.state.error, 'status.remoteUnavailable'); +}); + +test('a write locks the pages, sends every slot, and reports the read-back', async () => { + const write = deferred(); + const writes = []; + let stored = {primary: 'm-primary', fast: null, image_understanding: null}; + const sheet = sheetWith({ + getModelCatalog: async () => catalog(stored), + setModelDefaults: async (primary, fast, image) => { + writes.push([primary, fast, image]); + stored = {primary, fast, image_understanding: image}; + return write.promise; + } + }, 5); + sheet.setGate(gate(true, true, 'desk-1')); + await sheet.ensureLoaded(); + + const pending = sheet.selectRole('fast', 'm-vision'); + assert.equal(sheet.state.busy, true); + assert.equal(sheet.feedbackVisible, false); + assert.deepEqual(writes, [['m-primary', 'm-vision', null]]); + assert.equal(sheet.pendingRole, 'fast'); + assert.equal(sheet.pendingModelId, 'm-vision'); + + // A second tap while the first write is out cannot overwrite the intent the + // retry and the result line are read from. + await sheet.selectRole('fast', 'm-primary'); + assert.equal(writes.length, 1); + assert.equal(sheet.pendingModelId, 'm-vision'); + + write.resolve({}); + await pending; + assert.equal(sheet.state.busy, false); + assert.equal(sheet.state.roles.fast, 'm-vision'); + assert.equal(sheet.feedbackVisible, true); + await wait(40); + assert.equal(sheet.feedbackVisible, false); +}); + +test('selecting the value a role already holds writes nothing and says nothing', async () => { + let writes = 0; + const sheet = sheetWith({ + getModelCatalog: async () => catalog({primary: 'm-primary', fast: 'm-vision'}), + setModelDefaults: async () => { writes += 1; return {}; } + }, 5); + sheet.setGate(gate(true, true, 'desk-1')); + await sheet.ensureLoaded(); + + await sheet.selectRole('primary', 'm-primary'); + await sheet.selectRole('fast', 'm-vision'); + assert.equal(writes, 0); + assert.equal(sheet.feedbackVisible, false); + assert.equal(sheet.state.busy, false); +}); + +test('a rejected write keeps the host error verbatim and retries the same write', async () => { + const writes = []; + const sheet = sheetWith({ + getModelCatalog: async () => catalog({primary: 'm-primary'}), + setModelDefaults: async (primary, fast, image) => { + writes.push([primary, fast, image]); + if (writes.length === 1) { + throw new Error('Unknown model id: m-missing'); + } + return {}; + } + }, 5); + sheet.setGate(gate(true, true, 'desk-1')); + await sheet.ensureLoaded(); + + await sheet.selectRole('primary', 'm-missing'); + assert.equal(sheet.state.error, 'Unknown model id: m-missing'); + assert.equal(sheet.state.failure, 'write'); + assert.equal(sheet.feedbackVisible, false); + // The value never moved: the write was refused, so the device still holds it. + assert.equal(sheet.state.roles.primary, 'm-primary'); + assert.deepEqual(writes, [['m-missing', null, null]]); + + await sheet.retry(); + assert.equal(writes.length, 2); + assert.deepEqual(writes[1], ['m-missing', null, null]); + assert.equal(sheet.state.error, ''); + assert.equal(sheet.feedbackVisible, true); +}); + +test('a read that fails after an accepted write is retried as a read', async () => { + let reads = 0; + const sheet = sheetWith({ + getModelCatalog: async () => { + reads += 1; + if (reads === 2) { + throw new Error('Remote transport is not connected.'); + } + return catalog({primary: reads === 1 ? 'm-primary' : 'm-vision'}); + }, + setModelDefaults: async () => ({}) + }, 5); + sheet.setGate(gate(true, true, 'desk-1')); + await sheet.ensureLoaded(); + + await sheet.selectRole('primary', 'm-vision'); + assert.equal(sheet.state.failure, 'load'); + assert.equal(sheet.state.error, 'Remote transport is not connected.'); + assert.equal(sheet.feedbackVisible, false); + assert.equal(sheet.state.roles.primary, 'm-primary'); + + await sheet.retry(); + assert.equal(reads, 3); + assert.equal(sheet.state.error, ''); + assert.equal(sheet.state.roles.primary, 'm-vision'); + // A re-read confirms nothing new was written, so it does not claim an update. + assert.equal(sheet.feedbackVisible, false); +}); + +test('a write that lands after the sheet closed is dropped', async () => { + const write = deferred(); + const sheet = sheetWith({ + getModelCatalog: async () => catalog({primary: 'm-primary'}), + setModelDefaults: async () => write.promise + }, 5); + sheet.setGate(gate(true, true, 'desk-1')); + await sheet.ensureLoaded(); + + const pending = sheet.selectRole('primary', 'm-vision'); + sheet.reset(); + write.resolve({primary: 'm-vision'}); + await pending; + assert.equal(sheet.state.available, false); + assert.equal(sheet.state.busy, false); + assert.equal(sheet.feedbackVisible, false); + assert.deepEqual(sheet.state.roles, {primary: null, fast: null, image_understanding: null}); +}); + +test('the page starts from the controller own unavailable snapshot', () => { + const {DeviceModelDefaultsSheetState, emptyModelDefaultsState} = sheetStateModule(); + const {RemoteModelDefaultsController} = controllerModule(); + const controller = new RemoteModelDefaultsController({ + getModelCatalog: async () => emptyCatalog(), + setModelDefaults: async () => ({}) + }, {onStateChanged: () => {}}); + // The copy the state object carries as its initial value has to stay the shape + // the controller publishes, or a page mounted before `bind` renders nonsense. + assert.deepEqual(emptyModelDefaultsState(), controller.getState()); + assert.deepEqual(new DeviceModelDefaultsSheetState().state, controller.getState()); + assert.equal(DeviceModelDefaultsSheetState.name, 'DeviceModelDefaultsSheetState'); +}); + +// --------------------------------------------------------------------------- +// The picker over a device that stopped answering +// --------------------------------------------------------------------------- + +test('a gate that drops while the picker is open states the reason, not a choice', async () => { + const {ModelRoleCandidatesPolicy: policy} = policyModule(); + const sheet = sheetWith({ + getModelCatalog: async () => catalog({primary: 'm-primary'}), + setModelDefaults: async () => ({}) + }); + assert.equal(sheet.setGate(gate(true, true, 'desk-1')), true); + await sheet.ensureLoaded(); + + // The frame the picker draws for a device that is answering: the list is the + // device's, and the mark on the pinned row is the value it reported. + assert.deepEqual(policy.pickerView(sheet.state), { + reason: false, retry: false, reading: false, list: true, answered: true + }); + assert.equal(policy.isUnsetSelected(sheet.state.roles, 'fast'), true); + + // The link drops while the picker is up. The snapshot keeps the roles it was + // read with, so the raw predicate still calls the empty slot a choice — which + // is exactly the sentence the page must not draw from it. + assert.equal(sheet.setGate(gate(true, false, 'desk-1')), true); + assert.equal(sheet.state.error, 'status.remoteUnavailable'); + assert.equal(policy.isUnsetSelected(sheet.state.roles, 'fast'), true); + const dropped = policy.pickerView(sheet.state); + assert.equal(dropped.reason, true, 'the page states the reason instead of a list'); + assert.equal(dropped.answered, false, 'a snapshot the gate replaced answers nothing'); + assert.equal(dropped.list, false, 'a device that cannot answer has no list to offer'); + // Reconnecting is the sheet's own business: it re-arms the gate when the link + // returns, so there is nothing here for a retry to repeat. + assert.equal(dropped.retry, false); + assert.equal(policy.canRepeatRead(sheet.state), false); + + // An older desktop is the same page, with the reason it has. + assert.equal(sheet.setGate(gate(false, true, 'desk-1')), true); + assert.equal(sheet.state.error, 'settings.model.unsupported'); + assert.equal(policy.pickerView(sheet.state).reason, true); + assert.equal(policy.pickerView(sheet.state).retry, false); +}); + +test('a read that fails while the picker is open keeps the rows but not their marks', async () => { + const {ModelRoleCandidatesPolicy: policy} = policyModule(); + let reads = 0; + const sheet = sheetWith({ + getModelCatalog: async () => { + reads += 1; + if (reads === 2) { + throw new Error('The desktop stopped answering the model catalog.'); + } + return catalog({primary: 'm-primary'}); + }, + setModelDefaults: async () => ({}) + }); + sheet.setGate(gate(true, true, 'desk-1')); + await sheet.ensureLoaded(); + + // Another desktop is a different answer, so the page reads again — and this + // read is the one that fails. + assert.equal(sheet.setGate(gate(true, true, 'desk-2')), true); + await sheet.load(); + assert.equal(sheet.state.failure, 'load'); + assert.equal(sheet.state.error, 'The desktop stopped answering the model catalog.'); + + const view = policy.pickerView(sheet.state); + assert.equal(view.reason, true, 'the host\'s own words are what the page states'); + assert.equal(view.retry, true, 'repeating the read is the way out of a failed read'); + assert.equal(view.list, true, 'the device can still answer, so its rows stay'); + assert.equal(view.answered, false, 'but the last read is not this desktop\'s answer'); + assert.equal(view.reading, false); + + // The retry repeats that read and nothing else. + assert.equal(reads, 2); + await sheet.retry(); + assert.equal(reads, 3); + assert.equal(sheet.state.error, ''); + assert.deepEqual(policy.pickerView(sheet.state), { + reason: false, retry: false, reading: false, list: true, answered: true + }); +}); + +test('a picker opened before the first read says so instead of blaming the catalog', () => { + const {ModelRoleCandidatesPolicy: policy} = policyModule(); + const sheet = sheetWith({ + getModelCatalog: async () => catalog(), + setModelDefaults: async () => ({}) + }); + // Armed, and not read: a sheet restored straight onto the picker is here. + assert.equal(sheet.setGate(gate(true, true, 'desk-1')), true); + const view = policy.pickerView(sheet.state); + assert.equal(view.reading, true); + assert.equal(view.list, false); + assert.equal(view.answered, false, 'a list nobody has read yet is not an empty catalog'); + assert.equal(view.reason, false); +}); + +// --------------------------------------------------------------------------- +// Navigation +// --------------------------------------------------------------------------- + +test('the sheet stacks main into the model pages and closes back to the top', () => { + const {SettingsSheetState} = settingsSheetStateModule(); + const state = new SettingsSheetState(); + + assert.equal(state.page, 'main'); + assert.equal(state.pickRole, ''); + state.setPage('deviceModels'); + assert.equal(state.page, 'deviceModels'); + + state.openModelPick('fast'); + assert.equal(state.page, 'modelPick'); + assert.equal(state.pickRole, 'fast'); + + // Back returns exactly one level, to the page that pushed the picker. + state.backFromModelPick(); + assert.equal(state.page, 'deviceModels'); + assert.equal(state.pickRole, ''); + + // Any other route out of the picker disarms the role, so a page rebuilt from + // the wrong level cannot edit someone else's slot. + state.openModelPick('image_understanding'); + state.setPage('main'); + assert.equal(state.pickRole, ''); + state.openModelPick('image_understanding'); + state.setPage('deviceModels'); + assert.equal(state.pickRole, ''); + + state.openModelPick('primary'); + state.resetOnClose(); + assert.equal(state.page, 'main'); + assert.equal(state.pickRole, ''); + assert.equal(state.deviceModels.state.available, false); + assert.equal(state.deviceModels.state.loaded, false); +}); + +// --------------------------------------------------------------------------- +// Wiring the app has to keep +// --------------------------------------------------------------------------- + +test('the sheet routes both model pages and keeps no placeholder', () => { + const settingsSheet = source('entry/src/main/ets/pages/components/SettingsSheet.ets'); + + assert.equal(settingsSheet.includes('DeviceModelsPlaceholder'), false, + 'the placeholder page has to be gone once the real pages exist'); + assert.equal(settingsSheet.includes('deviceModelsPlaceholderText'), false, + 'the placeholder copy has to be gone with it'); + assert.match(settingsSheet, /page === 'deviceModels'/, 'the settings page must stay a page of the sheet'); + assert.match(settingsSheet, /page === 'modelPick'/, 'the picker must be a page of the same sheet'); + assert.match(settingsSheet, /this\.sheetState\.deviceModels\.bind\(/, 'the sheet owns the request path of the pages'); + assert.match(settingsSheet, /@Monitor\('connectionState', 'supportsDeviceModelDefaults', 'controlTargetDeviceId'\)/, + 'the gate has to follow the desktop, the link and its capability set'); +}); + +test('neither model page offers a manual refresh', () => { + const pages = [ + 'entry/src/main/ets/pages/components/DeviceModelDefaultsPanel.ets', + 'entry/src/main/ets/pages/components/ModelRolePickerPanel.ets' + ]; + for (const page of pages) { + const text = source(page); + assert.equal(text.includes("'common.refresh'"), false, + `${page} must not grow a manual refresh: opening the page is the read`); + // The write lock is what keeps a second write and an early exit out. + assert.match(text, /state\.busy|locked\(\)/, `${page} must answer the write lock`); + } +}); + +test('both model pages state a device that cannot answer', () => { + const picker = source('entry/src/main/ets/pages/components/ModelRolePickerPanel.ets'); + + // The card, the marks and the sentence about an empty list all come from one + // view of one state, so a dropped link cannot leave a marked row or an empty + // list being blamed on the catalog behind. + assert.match(picker, /if \(this\.view\(\)\.reason\) \{/, 'the reason has to replace the list'); + assert.match(picker, /if \(this\.view\(\)\.reading\) \{/, 'and an unread page has to say so'); + assert.match(picker, /if \(this\.view\(\)\.list\) \{/, 'the list is drawn only while it is an answer'); + assert.match(picker, /this\.view\(\)\.answered && this\.candidates\(\)\.length === 0/, + 'an empty list is only explained away when the device is the one saying it'); + assert.match(picker, /view\(\)\.answered &&/, 'the marks have to come from the view'); + assert.match(picker, /ModelUnavailableCard\(\{/, 'the picker states the reason in the shared card'); + assert.match(picker, /deviceModels\.retry\(\)/, 'and offers the retry the state allows'); + + // The settings page reaches the same card, so the two pages cannot describe the + // same desktop differently. + const settings = source('entry/src/main/ets/pages/components/DeviceModelDefaultsPanel.ets'); + assert.match(settings, /ModelUnavailableCard\(\{/, 'the settings page shares the card'); + assert.match(settings, /canRepeatRead\(this\.deviceModelState\(\)\)/, + 'and decides the retry from the same rule as the picker'); +}); + +test('the connected desktop offers the model settings entry the previews shoot', () => { + const normalize = text => text.replace(/\s+/g, ' '); + const immersive = source('entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets'); + const surfaces = source('entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets'); + const profile = source('entry/src/main/ets/pages/components/AccountProfilePanel.ets'); + const pageState = source('entry/src/main/ets/pages/state/RemotePageState.ets'); + const models = source('entry/src/main/ets/model/RemoteModels.ets'); + + // The chain behind the pill, end to end: the capability the desktop advertised + // is what the sheet is told, and what the row draws. + assert.match(pageState, /setHostCapabilities\(capabilities: string\[\]\): void \{/, + 'the host capability set is state the page carries'); + assert.match(pageState, /supportsHostCapability\(capability: string\): boolean \{/, + 'and the app asks it by capability'); + assert.match(normalize(surfaces), + /supportsDeviceModelDefaults: this\.remotePageState\.supportsHostCapability\( REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1\)/, + 'AppSettingsSurface has to hand the sheet the desktop\'s own capability'); + assert.match(normalize(immersive), /setHostCapabilities\(\[REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1\]\)/, + 'the settings fixture has to advertise it, or every shot of the entry is the greyed one'); + // A literal here would advertise a capability the app does not look for, so both + // ends name the constant the wire value is declared once. + assert.match(models, /REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1: string = 'device_model_defaults_v1'/); + assert.equal(/['"]device_model_defaults_v1['"]/.test(immersive), false, + 'the fixture has to name the constant, not its value'); + assert.equal(/['"]device_model_defaults_v1['"]/.test(surfaces), false); + + // The pill's enabled branch, distinct from the greyed one with its badge. + assert.match(profile, /entryEnabled\(this\.supportsDeviceModelDefaults\)/); + assert.match(profile, /\.opacity\(this\.deviceModelSettingsEnabled\(\) \? 1 : 0\.38\)/); + assert.match(profile, /if \(!this\.deviceModelSettingsEnabled\(\)\) \{/); + assert.match(profile, /settings\.model\.upgradeBadge/); + assert.match(profile, /\.id\(`device-model-entry-\$\{this\.deviceModelSettingsEnabled\(\) \? 'enabled' : 'degraded'\}`\)/, + 'the pill has to say which of its two states it is in'); +}); + +test('every key the two pages render exists in both catalogs', () => { + const catalogs = locale => new Set(JSON.parse( + source(`entry/src/main/resources/${locale}/element/string.json`) + ).string.map(entry => entry.name)); + const english = catalogs('en_US'); + const chinese = catalogs('zh_CN'); + const keys = [ + 'settings.model.settingsTitle', + 'settings.model.section', + 'settings.model.emptyTitle', + 'settings.model.emptyText', + 'settings.model.imageUnderstandingUnset', + 'settings.model.pickEmpty', + 'settings.model.capabilityImageUnderstanding', + 'settings.model.scopeNewSessions', + 'settings.model.scopeFastFollowsPrimary', + 'settings.model.scopeImageUnderstanding', + 'settings.modelService.notConfigured', + 'settings.modelService.accountSource', + 'settings.modelService.localSource', + 'chat.model', + 'chat.modelPrimary', + 'chat.modelFast', + 'chat.modelImage', + 'chat.modelFollowPrimary', + 'chat.modelFastNotConfigured', + 'connect.connected', + 'connect.disconnected', + 'common.retry', + 'status.loadingModelDefaults', + 'status.modelDefaultsUpdated', + 'status.remoteUnavailable' + ]; + // The three templated sentences are used with `RemoteI18n.f`, so they have to + // keep their placeholder in both locales. + const templated = [ + 'settings.model.filterTextChat', + 'settings.model.filterImageUnderstanding', + 'settings.model.updatedResult' + ]; + for (const key of keys.concat(templated)) { + const name = key.replace(/\./g, '_'); + assert.ok(english.has(name), `${key} is missing from en_US (${name})`); + assert.ok(chinese.has(name), `${key} is missing from zh_CN (${name})`); + } + const value = (locale, key) => JSON.parse( + source(`entry/src/main/resources/${locale}/element/string.json`) + ).string.find(entry => entry.name === key.replace(/\./g, '_')).value; + for (const key of templated) { + assert.ok(value('en_US', key).includes('%1$s'), `${key} must keep its placeholder in en_US`); + assert.ok(value('zh_CN', key).includes('%1$s'), `${key} must keep its placeholder in zh_CN`); + } +}); + +test('the device-model scenarios are one family the launcher can open', () => { + const entryAbility = source('entry/src/main/ets/entryability/EntryAbility.ets'); + const gallery = source('entry/src/main/ets/pages/preview/MobileDesignGallery.ets'); + const preview = source('entry/src/main/ets/pages/preview/DeviceModelDefaultsPreview.ets'); + + assert.match(entryAbility, /String\(scenarioId \|\| ''\)\.startsWith\('device-model'\)/, + 'the launcher has to recognize the whole device-model family'); + assert.match(entryAbility, /deviceModelScenario && String\(scenarioId\)\.endsWith\('-dark'\)/, + 'the dark variants have to lock the dark colour mode'); + assert.match(gallery, /startsWith\('device-model'\)/, + 'the gallery has to route the family to the preview'); + for (const scenario of [ + 'device-model-pick-fast', 'device-model-pick-multimodal', 'device-model-pick-primary', + 'device-model-pick-failed', 'device-model-degraded-pick', + 'device-model-writing', 'device-model-updated', 'device-model-degraded', 'device-model-empty' + ]) { + assert.ok(preview.includes(`'${scenario}'`), `${scenario} has to be a state the fixture can mount`); + } + // The two picker states with no list: a desktop that cannot answer the page and + // one that stopped answering it are different reasons, so they are different + // boards rather than one flag. + assert.match(preview, /this\.client\.failReads = scenario === 'device-model-pick-failed'/, + 'a failed read has to be a state the fixture can be put in'); + assert.match(preview, /scenario === 'device-model-degraded' \|\| scenario === 'device-model-degraded-pick'/, + 'the older desktop has to cover its picker too'); + // `device-models` is the settings page itself: the state every other scenario + // changes one thing away from, so it is the fixture's own default. + assert.match(preview, /scenario === 'device-model-empty' \? \[\] : previewModels\(\)/, + 'the settings page over a full catalog has to be the default state'); + // The preview mounts the production sheet rather than a picture of it. + assert.match(preview, /AppShell\(\{/, 'the fixture must mount the production shell'); + assert.match(preview, /AppSettingsSurface\(\{/, 'and the production settings surface'); +}); diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 983faaf78c..3a625636d3 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -43,22 +43,22 @@ use openbitfun_services_integrations::remote_connect::{ build_remote_model_catalog, normalize_remote_model_selection as normalize_remote_model_selection_contract, normalize_remote_session_model_id, project_remote_chat_user, - remote_dialog_submit_outcome_from_scheduler, remote_model_selection_needs_config, ChatMessage, - LocalModelsDevCatalogs, RemoteAssistantWorkspaceFacts, RemoteCancelRuntimeHost, - RemoteChatHistoryRound, RemoteChatHistoryTextItem, RemoteChatHistoryThinkingItem, - RemoteChatHistoryToolCall, RemoteChatHistoryToolItem, RemoteChatHistoryTurn, - RemoteConnectSubmissionSource, RemoteDefaultModelsConfig, RemoteDialogQueuePriority, - RemoteDialogResolvedSubmission, RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, - RemoteDialogSteerOutcome, RemoteDialogSteerRequest, RemoteDialogSubmissionPolicy, - RemoteDialogSubmitOutcome, RemoteDialogWorkspaceBinding, RemoteImageContext, - RemoteInitialSyncRuntimeHost, RemoteInteractionRuntimeHost, RemoteModelCapabilityFact, - RemoteModelCatalog, RemoteModelCatalogFacts, RemoteModelFacts, RemotePermissionMode, - RemotePollRuntimeHost, RemoteRecentWorkspaceFacts, RemoteSessionMetadata, - RemoteSessionModelSelection, RemoteSessionRollbackOutcome, RemoteSessionRuntimeHost, - RemoteSessionStateTracker, RemoteSessionTrackerHost, RemoteTerminalPrewarmRequest, - RemoteWorkspaceFacts, RemoteWorkspaceFileRuntimeHost, - RemoteWorkspaceKind as RemoteConnectWorkspaceKind, RemoteWorkspaceRuntimeHost, - RemoteWorkspaceUpdate, + remote_dialog_submit_outcome_from_scheduler, remote_model_selection_needs_config, + resolve_remote_model_default_slot, ChatMessage, LocalModelsDevCatalogs, + RemoteAssistantWorkspaceFacts, RemoteCancelRuntimeHost, RemoteChatHistoryRound, + RemoteChatHistoryTextItem, RemoteChatHistoryThinkingItem, RemoteChatHistoryToolCall, + RemoteChatHistoryToolItem, RemoteChatHistoryTurn, RemoteConnectSubmissionSource, + RemoteDefaultModelsConfig, RemoteDialogQueuePriority, RemoteDialogResolvedSubmission, + RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, RemoteDialogSteerOutcome, + RemoteDialogSteerRequest, RemoteDialogSubmissionPolicy, RemoteDialogSubmitOutcome, + RemoteDialogWorkspaceBinding, RemoteImageContext, RemoteInitialSyncRuntimeHost, + RemoteInteractionRuntimeHost, RemoteModelCapabilityFact, RemoteModelCatalog, + RemoteModelCatalogFacts, RemoteModelFacts, RemotePermissionMode, RemotePollRuntimeHost, + RemoteRecentWorkspaceFacts, RemoteSessionMetadata, RemoteSessionModelSelection, + RemoteSessionRollbackOutcome, RemoteSessionRuntimeHost, RemoteSessionStateTracker, + RemoteSessionTrackerHost, RemoteTerminalPrewarmRequest, RemoteWorkspaceFacts, + RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind as RemoteConnectWorkspaceKind, + RemoteWorkspaceRuntimeHost, RemoteWorkspaceUpdate, }; use std::sync::Arc; use std::time::Duration; @@ -87,7 +87,7 @@ use crate::infrastructure::ai::reasoning_catalog::{ use crate::service::remote_connect::remote_server::RemoteExecutionDispatcher; #[cfg(feature = "remote-connect")] -use crate::service::config::types::{AIConfig, GlobalConfig, ModelCapability}; +use crate::service::config::types::{AIConfig, DefaultModelsConfig, GlobalConfig, ModelCapability}; #[cfg(feature = "remote-connect")] use crate::service::session::{DialogTurnData, ToolItemIdentityExt, TurnStatus}; @@ -890,6 +890,85 @@ fn remote_model_capability_fact(capability: ModelCapability) -> RemoteModelCapab } } +/// Project the device `ai.default_models` object for a remote caller. Every +/// slot travels because a controller renders the whole role mapping, including +/// the slots only this device may write. +#[cfg(feature = "remote-connect")] +fn remote_default_models_config(defaults: &DefaultModelsConfig) -> RemoteDefaultModelsConfig { + RemoteDefaultModelsConfig { + primary: defaults.primary.clone(), + fast: defaults.fast.clone(), + search: defaults.search.clone(), + image_understanding: defaults.image_understanding.clone(), + image_generation: defaults.image_generation.clone(), + speech_recognition: defaults.speech_recognition.clone(), + } +} + +/// Merge a controller's three managed role defaults over the device's current +/// `ai` section, taken from the config write lock that will store the result. +/// +/// The managed slots are replaced as a whole object, so `None` leaves a slot +/// unset, and every provided id must name a model this device already +/// configures. The other three slots are carried over untouched. Enablement and +/// capability validity are deliberately not decided here: the config reconcile +/// owns that repair and the caller reports its result. +#[cfg(feature = "remote-connect")] +fn merge_remote_model_defaults( + ai_config: &AIConfig, + primary: Option<&str>, + fast: Option<&str>, + image_understanding: Option<&str>, +) -> Result { + let is_known_model = |model_id: &str| ai_config.models.iter().any(|model| model.id == model_id); + Ok(DefaultModelsConfig { + primary: resolve_remote_model_default_slot("primary", primary, &is_known_model)?, + fast: resolve_remote_model_default_slot("fast", fast, &is_known_model)?, + image_understanding: resolve_remote_model_default_slot( + "image_understanding", + image_understanding, + &is_known_model, + )?, + search: ai_config.default_models.search.clone(), + image_generation: ai_config.default_models.image_generation.clone(), + speech_recognition: ai_config.default_models.speech_recognition.clone(), + }) +} + +/// Merge, validate, and persist the remote-managed role slots. +/// +/// The `ai` section is read, changed, and written inside one config write lock, +/// so a desktop save to `ai.default_models` — or to `ai.models`, which the id +/// check reads — cannot be reverted by a snapshot taken before it. A rejected +/// id returns before anything is stored, and the manager's own `set` path +/// reconciles the object before it commits. +#[cfg(feature = "remote-connect")] +async fn persist_remote_model_defaults( + config_service: &crate::service::config::ConfigService, + primary: Option<&str>, + fast: Option<&str>, + image_understanding: Option<&str>, +) -> Result<(), String> { + let rejection = config_service + .update_config("ai", |ai: &mut AIConfig| { + match merge_remote_model_defaults(ai, primary, fast, image_understanding) { + Ok(merged) => { + ai.default_models = merged; + Ok(None) + } + // An unusable id is not a config failure: report it to the + // caller unchanged and leave the stored section alone. + Err(message) => Ok(Some(message)), + } + }) + .await + .map_err(|e| format!("Failed to persist device model defaults: {e}"))?; + match rejection { + Some(message) => Err(message), + None => Ok(()), + } +} + /// An attachment recorded before pixels travelled inline holds a host path and /// nothing else, and only this host can resolve it. Above this size the image is /// left as a name: reading it would cost more than the thumbnail is worth. @@ -2283,14 +2362,7 @@ impl CoreServiceAgentRuntime { models, provider_catalog, models_dev_reasoning_catalog, - default_models: RemoteDefaultModelsConfig { - primary: ai_config.default_models.primary, - fast: ai_config.default_models.fast, - search: ai_config.default_models.search, - image_understanding: ai_config.default_models.image_understanding, - image_generation: ai_config.default_models.image_generation, - speech_recognition: ai_config.default_models.speech_recognition, - }, + default_models: remote_default_models_config(&ai_config.default_models), session_model_id, session_reasoning_preset, })) @@ -2465,6 +2537,32 @@ impl CoreServiceAgentRuntime { .await; } + /// Apply a controller's three device role defaults and report the device's + /// reconciled six-slot mapping. + /// + /// The merge, its validation, and the persist share one config write lock + /// (see [`persist_remote_model_defaults`]), so a concurrent desktop save to + /// the same section cannot be reverted. The reply is read back from the + /// reconciled config: a slot whose model is disabled or lacks the capability + /// its role needs reports the repaired value rather than what was requested. + #[cfg(feature = "remote-connect")] + pub(crate) async fn update_remote_model_defaults( + primary: Option<&str>, + fast: Option<&str>, + image_understanding: Option<&str>, + ) -> Result { + let config_service = crate::service::config::get_global_config_service() + .await + .map_err(|_| "Config service not available".to_string())?; + persist_remote_model_defaults(&config_service, primary, fast, image_understanding).await?; + + let reconciled: AIConfig = config_service + .get_config(Some("ai")) + .await + .map_err(|e| format!("Failed to reload AI config: {e}"))?; + Ok(remote_default_models_config(&reconciled.default_models)) + } + #[cfg(feature = "remote-connect")] pub(crate) fn remote_control_state_port( coordinator: &ConversationCoordinator, @@ -3580,6 +3678,18 @@ impl RemoteSessionRuntimeHost for CoreRemoteSessionRuntimeHost { .await } + /// Device-level role defaults: no session or workspace is involved, and the + /// reply is the device's reconciled mapping rather than the request. + async fn set_model_defaults( + &self, + primary: Option<&str>, + fast: Option<&str>, + image_understanding: Option<&str>, + ) -> Result { + CoreServiceAgentRuntime::update_remote_model_defaults(primary, fast, image_understanding) + .await + } + async fn ensure_session_loaded(&self, session_id: &str) -> Result<(), String> { let binding = CoreServiceAgentRuntime::resolve_session_workspace_binding(session_id) .await @@ -4373,6 +4483,245 @@ mod tests { assert!(!body.contains("restore_session_from_storage_path")); } + fn device_default_model_catalog() -> GlobalConfig { + let mut config = GlobalConfig::default(); + config.ai = serde_json::from_value(serde_json::json!({ + "models": [ + {"id": "text-1", "name": "Text", "provider": "openai", "enabled": true, + "capabilities": ["text_chat"]}, + {"id": "vision-1", "name": "Vision", "provider": "openai", "enabled": true, + "capabilities": ["image_understanding"]}, + {"id": "retired-fast", "name": "Retired", "provider": "openai", "enabled": false, + "capabilities": ["text_chat"]}, + {"id": "search-1", "name": "Search", "provider": "openai", "enabled": true, + "capabilities": ["search"]}, + {"id": "image-1", "name": "Image", "provider": "openai", "enabled": true, + "capabilities": ["image_generation"]}, + {"id": "speech-1", "name": "Speech", "provider": "openai", "enabled": true, + "capabilities": ["speech_recognition"]} + ], + "default_models": { + "primary": "text-1", + "search": "search-1", + "image_generation": "image-1", + "speech_recognition": "speech-1" + } + })) + .expect("device model catalog fixture"); + config + } + + #[test] + fn device_model_defaults_merge_owns_three_slots_and_preserves_host_slots() { + let mut config = device_default_model_catalog(); + config.ai.default_models.fast = Some("retired-fast".to_string()); + config.ai.default_models.image_understanding = Some("vision-1".to_string()); + + // A cleared slot (absent field or explicit null) is a real write. + let cleared = merge_remote_model_defaults(&config.ai, None, None, None).unwrap(); + assert_eq!(cleared.primary, None); + assert_eq!(cleared.fast, None); + assert_eq!(cleared.image_understanding, None); + // The three slots only this device writes survive untouched. + assert_eq!(cleared.search.as_deref(), Some("search-1")); + assert_eq!(cleared.image_generation.as_deref(), Some("image-1")); + assert_eq!(cleared.speech_recognition.as_deref(), Some("speech-1")); + + let assigned = + merge_remote_model_defaults(&config.ai, Some("text-1"), None, Some("vision-1")) + .unwrap(); + assert_eq!(assigned.primary.as_deref(), Some("text-1")); + assert_eq!(assigned.fast, None); + assert_eq!(assigned.image_understanding.as_deref(), Some("vision-1")); + assert_eq!(assigned.search.as_deref(), Some("search-1")); + assert_eq!(assigned.image_generation.as_deref(), Some("image-1")); + assert_eq!(assigned.speech_recognition.as_deref(), Some("speech-1")); + + // A model this device does not configure, a semantic session selector, + // and an empty value are all rejected before anything is persisted. + assert_eq!( + merge_remote_model_defaults(&config.ai, Some("model-gone"), None, None).unwrap_err(), + "Unknown model selection: model-gone" + ); + assert!(merge_remote_model_defaults(&config.ai, None, Some("fast"), None).is_err()); + assert!(merge_remote_model_defaults(&config.ai, None, None, Some(" ")).is_err()); + } + + #[test] + fn device_model_defaults_echo_the_reconciled_device_state() { + use crate::service::config::reconcile_model_references; + + let mut config = device_default_model_catalog(); + // `retired-fast` exists but is disabled, and `text-1` cannot understand + // images: both pass the verb's existence check, so the request is a real + // write and only the reconcile can repair it. + let requested = merge_remote_model_defaults( + &config.ai, + Some("text-1"), + Some("retired-fast"), + Some("text-1"), + ) + .unwrap(); + assert_eq!(requested.fast.as_deref(), Some("retired-fast")); + assert_eq!(requested.image_understanding.as_deref(), Some("text-1")); + + config.ai.default_models = requested.clone(); + reconcile_model_references(&mut config); + + let echo = remote_default_models_config(&config.ai.default_models); + // The reply is the device's repaired truth, not what was requested. + assert_ne!(echo.fast, requested.fast); + assert_eq!(echo.fast.as_deref(), Some("text-1")); + assert_ne!(echo.image_understanding, requested.image_understanding); + assert_eq!(echo.image_understanding.as_deref(), Some("vision-1")); + assert_eq!(echo.primary.as_deref(), Some("text-1")); + // Host-only slots stay out of the repair entirely. + assert_eq!(echo.search.as_deref(), Some("search-1")); + assert_eq!(echo.image_generation.as_deref(), Some("image-1")); + assert_eq!(echo.speech_recognition.as_deref(), Some("speech-1")); + } + + /// The write path against a real config service and a real reconcile: the + /// stored section is read inside the same critical section as the store, the + /// device-owned slots survive, a cleared `primary` is re-filled, and a + /// rejected id is never a partial write. + #[tokio::test] + async fn device_model_defaults_write_behavior_against_a_real_config_service() { + use crate::service::config::{ConfigManagerSettings, ConfigService}; + + let dir = tempfile::tempdir().expect("tempdir"); + let path_manager = Arc::new( + crate::infrastructure::PathManager::with_user_root_for_tests( + dir.path().join("remote-device-model-defaults"), + ), + ); + let service = ConfigService::with_settings(ConfigManagerSettings { + path_manager: Some(path_manager.clone()), + auto_save: true, + backup_count: 0, + }) + .await + .expect("config service"); + + let seeded = device_default_model_catalog(); + service + .set_config("ai.models", seeded.ai.models) + .await + .expect("seed device models"); + service + .set_config( + "ai.default_models", + serde_json::to_value(&seeded.ai.default_models).expect("seed defaults"), + ) + .await + .expect("seed device role defaults"); + + persist_remote_model_defaults(&service, Some("text-1"), None, Some("vision-1")) + .await + .expect("remote device defaults write"); + + let ai: AIConfig = service.get_config(Some("ai")).await.expect("ai config"); + assert_eq!(ai.default_models.primary.as_deref(), Some("text-1")); + assert_eq!(ai.default_models.fast, None); + assert_eq!( + ai.default_models.image_understanding.as_deref(), + Some("vision-1") + ); + // The device-owned slots came from the section the write lock read, not + // from a snapshot taken before it, and reached disk. + assert_eq!(ai.default_models.search.as_deref(), Some("search-1")); + assert_eq!( + ai.default_models.image_generation.as_deref(), + Some("image-1") + ); + assert_eq!( + ai.default_models.speech_recognition.as_deref(), + Some("speech-1") + ); + let persisted: GlobalConfig = serde_json::from_slice( + &tokio::fs::read(path_manager.app_config_file()) + .await + .expect("persisted config"), + ) + .expect("valid persisted config"); + assert_eq!( + persisted.ai.default_models.image_understanding.as_deref(), + Some("vision-1") + ); + assert_eq!( + persisted.ai.default_models.search.as_deref(), + Some("search-1") + ); + + // A cleared primary does not stay unset: the reconcile re-fills it with + // the first enabled text-capable model, while the optional slots stay + // unconfigured. + persist_remote_model_defaults(&service, None, None, None) + .await + .expect("clear device roles"); + let ai: AIConfig = service.get_config(Some("ai")).await.expect("ai config"); + assert_eq!(ai.default_models.primary.as_deref(), Some("text-1")); + assert_eq!(ai.default_models.fast, None); + assert_eq!(ai.default_models.image_understanding, None); + + // A rejected id is reported before anything is stored. + let before: AIConfig = service.get_config(Some("ai")).await.expect("ai config"); + assert_eq!( + persist_remote_model_defaults(&service, Some("model-gone"), None, None) + .await + .expect_err("unknown model ids are rejected"), + "Unknown model selection: model-gone" + ); + let after: AIConfig = service.get_config(Some("ai")).await.expect("ai config"); + assert_eq!( + serde_json::to_value(&after.default_models).expect("defaults json"), + serde_json::to_value(&before.default_models).expect("defaults json") + ); + } + + #[test] + fn device_model_defaults_write_merges_and_persists_under_one_config_lock() { + let source = include_str!("service_agent_runtime.rs"); + let merge_and_persist = source + .split("async fn persist_remote_model_defaults") + .nth(1) + .and_then(|source| { + source + .split("/// An attachment recorded before pixels travelled inline") + .next() + }) + .expect("device model defaults writer"); + + // The merge, its validation, and the store happen inside one + // `update_config` closure: a read before the write lock would let a + // concurrent desktop save be reverted by a stale snapshot. + assert!(merge_and_persist.contains(r#".update_config("ai", |ai: &mut AIConfig|"#)); + assert!(merge_and_persist.contains("merge_remote_model_defaults(ai, primary, fast")); + assert!(merge_and_persist.contains("ai.default_models = merged;")); + assert!(!merge_and_persist.contains("get_config(")); + assert!(!merge_and_persist.contains("set_config(")); + + let caller = source + .split("pub(crate) async fn update_remote_model_defaults") + .nth(1) + .and_then(|source| { + source + .split("pub(crate) fn remote_control_state_port") + .next() + }) + .expect("device model defaults verb"); + // The echo is read back from the committed, reconciled config rather + // than built from the merged request. + assert!(caller.contains("persist_remote_model_defaults(&config_service")); + assert!(caller.contains("remote_default_models_config(&reconciled.default_models)")); + for slot in ["search", "image_generation", "speech_recognition"] { + assert!( + !caller.contains(&format!("ai.default_models.{slot}")), + "host-only slot {slot} must not be written by the remote verb" + ); + } + } + #[test] fn remote_generated_turn_ids_are_uuid_unique() { let ids = (0..1_024) diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index a270ced364..15b9639cdf 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -559,6 +559,11 @@ pub const REMOTE_CAPABILITY_DIALOG_STEER_V1: &str = "dialog_steer_v1"; pub const REMOTE_CAPABILITY_USER_QUESTION_INTERACTION_V1: &str = "user_question_interaction_v1"; pub const REMOTE_CAPABILITY_SESSION_ROLLBACK_V1: &str = "session_rollback_v1"; pub const REMOTE_CAPABILITY_PLAN_BUILD_V1: &str = "plan_build_v1"; +/// Device-level role defaults (primary / fast / image understanding) are +/// readable and writable over the typed `set_model_defaults` verb. A caller +/// must check this string before offering the write; a host without it can +/// still answer `get_model_catalog` read-only. +pub const REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1: &str = "device_model_defaults_v1"; pub use host_stream::REMOTE_CAPABILITY_HOST_STREAM_V1; fn remote_host_capabilities() -> Vec { @@ -571,6 +576,7 @@ fn remote_host_capabilities() -> Vec { REMOTE_CAPABILITY_SESSION_ROLLBACK_V1.to_string(), REMOTE_CAPABILITY_USER_QUESTION_INTERACTION_V1.to_string(), REMOTE_CAPABILITY_HOST_STREAM_V1.to_string(), + REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1.to_string(), ] } @@ -1353,6 +1359,16 @@ pub fn remote_session_model_updated_response( } } +/// Report the host's post-reconcile device role defaults. The payload is the +/// host's own mapping, never an echo of the request: a slot the host had to +/// repair (disabled model, missing capability) already shows the repaired +/// value, so a controller can display the truth instead of an optimistic copy. +pub fn remote_model_defaults_updated_response( + default_models: RemoteDefaultModelsConfig, +) -> RemoteResponse { + RemoteResponse::ModelDefaultsUpdated { default_models } +} + pub fn remote_messages_response( session_id: impl Into, messages: Vec, @@ -1434,6 +1450,25 @@ pub trait RemoteSessionRuntimeHost: Send + Sync { model_id: &str, reasoning_preset: Option>, ) -> Result; + /// Replace the three device role defaults a controller configures for this + /// host (`primary`, `fast`, `image_understanding`); host-only slots such as + /// `search` and `image_generation` always keep their device values. A slot + /// sent as `None` is written as unset: `fast` then follows primary, and + /// `image_understanding` is unconfigured. A cleared `primary` does not stay + /// unset — the device reconcile re-fills it with the first enabled + /// text-capable model. The returned mapping is the host's reconciled truth + /// for all six slots, not the requested patch. + /// + /// A host that cannot configure device defaults reports an unsupported + /// error here instead of answering with an unchanged-looking success. + async fn set_model_defaults( + &self, + _primary: Option<&str>, + _fast: Option<&str>, + _image_understanding: Option<&str>, + ) -> Result { + Err("Host does not support device model defaults".to_string()) + } async fn ensure_session_loaded(&self, session_id: &str) -> Result<(), String>; async fn update_session_title(&self, session_id: &str, title: &str) -> Result; async fn resolve_session_storage_dir(&self, session_id: &str) -> Option; @@ -1674,6 +1709,26 @@ where Ok(selection) => remote_session_model_updated_response(session_id.clone(), selection), Err(message) => RemoteResponse::Error { message }, }, + RemoteCommand::SetModelDefaults { + primary, + fast, + image_understanding, + } => { + // Device-level: the command names no session or workspace. The + // three fields are a whole-object write, so an absent field is + // already a cleared slot by deserialization. + match host + .set_model_defaults( + primary.as_deref(), + fast.as_deref(), + image_understanding.as_deref(), + ) + .await + { + Ok(default_models) => remote_model_defaults_updated_response(default_models), + Err(message) => RemoteResponse::Error { message }, + } + } RemoteCommand::UpdateSessionTitle { session_id, title } => { if let Err(message) = host.ensure_session_loaded(session_id).await { return RemoteResponse::Error { message }; @@ -2186,6 +2241,32 @@ pub fn normalize_remote_model_selection( .ok_or_else(|| format!("Unknown model selection: {requested_model_id}")) } +/// Validate one managed device role-default slot of `set_model_defaults`. +/// +/// A provided value must name a concrete model the device already configures; +/// the semantic selectors (`primary`, `fast`, `auto`, `default`) choose a +/// *session* model and are never a device default value. `None` clears the +/// slot. Whether the named model is enabled or carries the capability its slot +/// needs is deliberately not decided here: the config reconcile owns that +/// repair and the response reports its result. +pub fn resolve_remote_model_default_slot( + slot: &str, + model_id: Option<&str>, + is_known_model: impl Fn(&str) -> bool, +) -> Result, String> { + let Some(model_id) = model_id else { + return Ok(None); + }; + let model_id = model_id.trim(); + if model_id.is_empty() { + return Err(format!("{slot} model id is required")); + } + if !is_known_model(model_id) { + return Err(format!("Unknown model selection: {model_id}")); + } + Ok(Some(model_id.to_string())) +} + #[derive(Debug, Clone, PartialEq)] pub struct RemoteModelCatalogPollDelta { pub changed: bool, @@ -2710,6 +2791,28 @@ pub enum RemoteCommand { #[serde(default, deserialize_with = "deserialize_present_nullable")] reasoning_preset: Option>, }, + /// Replace the device-level role defaults (`ai.default_models.primary` / + /// `.fast` / `.image_understanding`) of the receiving host. + /// + /// This is a whole-object write of the three managed slots: an absent field + /// and an explicit `null` both leave that slot unset, so the command has no + /// tri-state ambiguity — a caller that wants to keep a slot must send its + /// current catalog value. Clearing is not uniform: unset `fast` follows + /// primary and unset `image_understanding` stays unconfigured, while unset + /// `primary` falls back to the device's first enabled text-capable model + /// once the host reconciles, so the echoed mapping stays authoritative. + /// Host-only slots (`search`, `image_generation`, `speech_recognition`) are + /// never part of the request and keep their device values. Values must be + /// concrete catalog model ids; a semantic selector is rejected. Requires the + /// `device_model_defaults_v1` capability. + SetModelDefaults { + #[serde(default)] + primary: Option, + #[serde(default)] + fast: Option, + #[serde(default)] + image_understanding: Option, + }, UpdateSessionTitle { session_id: String, title: String, @@ -2985,6 +3088,12 @@ pub enum RemoteResponse { model_id: String, reasoning_preset: Option, }, + /// All six device role-default slots after the host reconciled the write. + /// The body is a role mapping only: it carries model ids, never model + /// credentials. + ModelDefaultsUpdated { + default_models: RemoteDefaultModelsConfig, + }, SessionTitleUpdated { session_id: String, title: String, @@ -3217,6 +3326,7 @@ where | RemoteCommand::CreateSession { .. } | RemoteCommand::GetModelCatalog { .. } | RemoteCommand::SetSessionModel { .. } + | RemoteCommand::SetModelDefaults { .. } | RemoteCommand::UpdateSessionTitle { .. } | RemoteCommand::GetSessionMessages { .. } | RemoteCommand::DeleteSession { .. } @@ -4736,6 +4846,8 @@ mod tests { created_requests: Mutex>, list_identities: Mutex>, model_updates: Mutex>)>>, + model_default_updates: Mutex, Option, Option)>>, + model_defaults_error: Option, removed_trackers: Mutex>, history_error: Option, rollback_requests: Mutex)>>, @@ -4899,6 +5011,34 @@ mod tests { Ok(()) } + async fn set_model_defaults( + &self, + primary: Option<&str>, + fast: Option<&str>, + image_understanding: Option<&str>, + ) -> Result { + self.model_default_updates.lock().unwrap().push(( + primary.map(str::to_string), + fast.map(str::to_string), + image_understanding.map(str::to_string), + )); + if let Some(error) = self.model_defaults_error.as_ref() { + return Err(error.clone()); + } + // The fake answers with the host's reconciled mapping, not the + // request, mirroring a config service that repaired a slot. + Ok(RemoteDefaultModelsConfig { + primary: primary + .map(str::to_string) + .or(Some("primary-fallback".into())), + fast: fast.map(str::to_string), + search: Some("device-search".to_string()), + image_understanding: image_understanding.map(str::to_string), + image_generation: Some("device-image-generation".to_string()), + speech_recognition: Some("device-speech".to_string()), + }) + } + async fn update_session_title( &self, _session_id: &str, @@ -5241,6 +5381,186 @@ mod tests { ); } + #[tokio::test] + async fn remote_session_handler_forwards_device_model_defaults() { + let host = FakeSessionHost::default(); + + let response = handle_remote_session_command( + &host, + &RemoteCommand::SetModelDefaults { + primary: Some("model-primary".to_string()), + fast: None, + image_understanding: Some("model-vision".to_string()), + }, + ) + .await; + + // The response is the host's reconciled mapping, including the slots + // the request never carries. + assert_eq!( + response, + RemoteResponse::ModelDefaultsUpdated { + default_models: RemoteDefaultModelsConfig { + primary: Some("model-primary".to_string()), + fast: None, + search: Some("device-search".to_string()), + image_understanding: Some("model-vision".to_string()), + image_generation: Some("device-image-generation".to_string()), + speech_recognition: Some("device-speech".to_string()), + }, + } + ); + assert_eq!( + host.model_default_updates.lock().unwrap().as_slice(), + [( + Some("model-primary".to_string()), + None, + Some("model-vision".to_string()), + )] + ); + } + + #[tokio::test] + async fn remote_session_handler_reports_device_model_defaults_error() { + let host = FakeSessionHost { + model_defaults_error: Some("Unknown model selection: model-gone".to_string()), + ..FakeSessionHost::default() + }; + + let response = handle_remote_session_command( + &host, + &RemoteCommand::SetModelDefaults { + primary: Some("model-gone".to_string()), + fast: None, + image_understanding: None, + }, + ) + .await; + + assert_eq!( + response, + RemoteResponse::Error { + message: "Unknown model selection: model-gone".to_string(), + } + ); + } + + /// A host that does not implement device role defaults must say so instead + /// of answering with an unchanged-looking success. + #[tokio::test] + async fn remote_session_host_without_device_model_defaults_reports_unsupported() { + struct LegacySessionHost; + + #[async_trait::async_trait] + impl RemoteSessionRuntimeHost for LegacySessionHost { + async fn resolve_legacy_workspace( + &self, + _workspace_path: &str, + _remote_connection_id: Option<&str>, + _remote_ssh_host: Option<&str>, + ) -> Result, String> { + Ok(None) + } + + async fn list_session_metadata( + &self, + _workspace_path: &Path, + _workspace_identity: RemoteSessionWorkspaceIdentity, + ) -> Result, String> { + Ok(Vec::new()) + } + + async fn resolve_default_assistant_workspace( + &self, + ) -> Result { + Err("no assistant workspace".to_string()) + } + + async fn create_session( + &self, + _request: AgentSessionCreateRequest, + ) -> Result { + Err("no session creation".to_string()) + } + + async fn load_model_catalog( + &self, + _session_id: Option<&str>, + ) -> Result { + Err("no catalog".to_string()) + } + + async fn update_session_model_selection( + &self, + _session_id: &str, + _model_id: &str, + _reasoning_preset: Option>, + ) -> Result { + Err("no session model selection".to_string()) + } + + async fn ensure_session_loaded(&self, _session_id: &str) -> Result<(), String> { + Err("no session restore".to_string()) + } + + async fn update_session_title( + &self, + _session_id: &str, + _title: &str, + ) -> Result { + Err("no title update".to_string()) + } + + async fn resolve_session_storage_dir(&self, _session_id: &str) -> Option { + None + } + + async fn load_remote_chat_messages( + &self, + _session_storage_dir: &Path, + _session_id: &str, + ) -> Result<(Vec, bool), String> { + Err("no history".to_string()) + } + + async fn rollback_session_to_turn( + &self, + _session_id: &str, + _target_turn_id: &str, + _expected_storage_turn_index: Option, + ) -> Result { + Err("no rollback".to_string()) + } + + async fn delete_session( + &self, + _session_storage_dir: &Path, + _session_id: &str, + ) -> Result<(), String> { + Err("no delete".to_string()) + } + + fn remove_tracker(&self, _session_id: &str) {} + } + + let response = handle_remote_session_command( + &LegacySessionHost, + &RemoteCommand::SetModelDefaults { + primary: None, + fast: None, + image_understanding: None, + }, + ) + .await; + + assert_eq!( + response, + RemoteResponse::Error { + message: "Host does not support device model defaults".to_string(), + } + ); + } + #[tokio::test] async fn remote_session_handler_forwards_rollback_target_and_guard() { let host = FakeSessionHost::default(); diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index ffb4e9ce5c..8d9d439d37 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -23,7 +23,8 @@ use openbitfun_services_integrations::remote_connect::{ remote_dialog_submit_outcome_from_scheduler, remote_dialog_submit_response, remote_file_chunk_response, remote_file_content_response, remote_file_display_name, remote_file_info_response, remote_initial_sync_response, remote_interaction_accepted_response, - remote_messages_response, remote_model_catalog_poll_delta, remote_model_selection_needs_config, + remote_messages_response, remote_model_catalog_poll_delta, + remote_model_defaults_updated_response, remote_model_selection_needs_config, remote_no_change_poll_response, remote_persisted_poll_response, remote_plan_build_content, remote_recent_workspaces_response, remote_session_created_response, remote_session_deleted_response, remote_session_info, remote_session_list_response, @@ -31,9 +32,10 @@ use openbitfun_services_integrations::remote_connect::{ remote_snapshot_poll_response, remote_task_cancel_response, remote_workspace_info_response, remote_workspace_updated_response, resolve_remote_agent_type, resolve_remote_cancel_decision, resolve_remote_execution_image_contexts, resolve_remote_file_chunk_range, - resolve_remote_workspace_path, should_send_remote_model_catalog, submit_remote_dialog, - ActiveTurnSnapshot, ChatImageAttachment, ChatMessage, ChatMessageItem, ImageAttachment, - QrGenerator, RemoteAssistantWorkspaceFacts, RemoteCancelDecision, RemoteCancelRuntimeHost, + resolve_remote_model_default_slot, resolve_remote_workspace_path, + should_send_remote_model_catalog, submit_remote_dialog, ActiveTurnSnapshot, + ChatImageAttachment, ChatMessage, ChatMessageItem, ImageAttachment, QrGenerator, + RemoteAssistantWorkspaceFacts, RemoteCancelDecision, RemoteCancelRuntimeHost, RemoteCancelTaskRequest, RemoteChatHistoryRound, RemoteChatHistoryTextItem, RemoteChatHistoryThinkingItem, RemoteChatHistoryToolCall, RemoteChatHistoryToolItem, RemoteChatHistoryTurn, RemoteCommand, RemoteCommandRuntimeHost, RemoteConnectSubmissionSource, @@ -47,7 +49,8 @@ use openbitfun_services_integrations::remote_connect::{ RemoteSessionTrackerHost, RemoteSessionTrackerRegistry, RemoteSessionWorkspaceIdentity, RemoteTerminalPrewarmRequest, RemoteToolStatus, RemoteWorkspaceFacts, RemoteWorkspaceFileChunk, RemoteWorkspaceFileContent, RemoteWorkspaceFileInfo, RemoteWorkspaceFileRuntimeHost, - RemoteWorkspaceKind, RemoteWorkspaceUpdate, TrackerEvent, REMOTE_CAPABILITY_DIALOG_STEER_V1, + RemoteWorkspaceKind, RemoteWorkspaceUpdate, TrackerEvent, + REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1, REMOTE_CAPABILITY_DIALOG_STEER_V1, REMOTE_CAPABILITY_HARNESS_PROFILES_V1, REMOTE_CAPABILITY_HOST_STREAM_V1, REMOTE_CAPABILITY_PLAN_BUILD_V1, REMOTE_CAPABILITY_SESSION_ROLLBACK_V1, REMOTE_FILE_MAX_CHUNK_BYTES, REMOTE_FILE_MAX_READ_BYTES, @@ -2227,7 +2230,8 @@ fn remote_connect_workspace_response_helpers_own_wire_shape() { REMOTE_CAPABILITY_PLAN_BUILD_V1, REMOTE_CAPABILITY_SESSION_ROLLBACK_V1, "user_question_interaction_v1", - REMOTE_CAPABILITY_HOST_STREAM_V1 + REMOTE_CAPABILITY_HOST_STREAM_V1, + REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1 ]) ); let mut legacy_info_json = info_json.clone(); @@ -2474,7 +2478,8 @@ fn remote_connect_session_response_helpers_own_pagination_and_timestamps() { REMOTE_CAPABILITY_PLAN_BUILD_V1, REMOTE_CAPABILITY_SESSION_ROLLBACK_V1, "user_question_interaction_v1", - REMOTE_CAPABILITY_HOST_STREAM_V1 + REMOTE_CAPABILITY_HOST_STREAM_V1, + REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1 ]) ); let mut legacy_initial_json = initial_json; @@ -3688,3 +3693,198 @@ fn dialog_queue_wire_keeps_legacy_send_messages_readable() { let command: RemoteCommand = serde_json::from_value(queue.clone()).unwrap(); assert_eq!(serde_json::to_value(command).unwrap(), queue); } + +#[test] +fn device_model_defaults_command_is_a_whole_object_write_of_three_slots() { + // Absent fields and explicit nulls both clear a slot, so the command has no + // tri-state ambiguity; the host-only slots are not part of the request. + let empty: RemoteCommand = + serde_json::from_value(serde_json::json!({ "cmd": "set_model_defaults" })) + .expect("deserialize empty device defaults command"); + assert_eq!( + empty, + RemoteCommand::SetModelDefaults { + primary: None, + fast: None, + image_understanding: None, + } + ); + let explicit_nulls: RemoteCommand = serde_json::from_value(serde_json::json!({ + "cmd": "set_model_defaults", + "primary": null, + "fast": "fast-model", + "image_understanding": null + })) + .expect("deserialize explicit null slots"); + assert_eq!( + explicit_nulls, + RemoteCommand::SetModelDefaults { + primary: None, + fast: Some("fast-model".to_string()), + image_understanding: None, + } + ); + let host_slots_are_ignored: RemoteCommand = serde_json::from_value(serde_json::json!({ + "cmd": "set_model_defaults", + "primary": "model-1", + "search": "search-model", + "speech_recognition": "speech-model" + })) + .expect("host-only slots are not part of the request"); + assert_eq!( + host_slots_are_ignored, + RemoteCommand::SetModelDefaults { + primary: Some("model-1".to_string()), + fast: None, + image_understanding: None, + } + ); + + let serialized = serde_json::to_value(RemoteCommand::SetModelDefaults { + primary: Some("model-1".to_string()), + fast: None, + image_understanding: Some("vision-1".to_string()), + }) + .expect("serialize device defaults command"); + assert_eq!( + serialized, + serde_json::json!({ + "cmd": "set_model_defaults", + "primary": "model-1", + "fast": null, + "image_understanding": "vision-1" + }) + ); +} + +#[test] +fn device_model_defaults_response_carries_role_mappings_only() { + let default_models = RemoteDefaultModelsConfig { + primary: Some("model-1".to_string()), + fast: Some("fast-model".to_string()), + search: Some("search-model".to_string()), + image_understanding: Some("vision-model".to_string()), + image_generation: Some("image-model".to_string()), + speech_recognition: Some("speech-model".to_string()), + }; + let response = remote_model_defaults_updated_response(default_models.clone()); + assert_eq!( + response, + RemoteResponse::ModelDefaultsUpdated { + default_models: default_models.clone(), + } + ); + + let json = serde_json::to_value(&response).expect("serialize device defaults response"); + assert_eq!(json["resp"], "model_defaults_updated"); + let mut response_keys: Vec = json + .as_object() + .expect("response is an object") + .keys() + .cloned() + .collect(); + response_keys.sort(); + assert_eq!(response_keys, ["default_models", "resp"]); + + // All six slots travel; the three host-only slots are reported even though + // the request never carries them. + let slots = json["default_models"] + .as_object() + .expect("default models is an object"); + let mut slot_keys: Vec = slots.keys().cloned().collect(); + slot_keys.sort(); + assert_eq!( + slot_keys, + [ + "fast", + "image_generation", + "image_understanding", + "primary", + "search", + "speech_recognition" + ] + ); + assert!(slots.values().all(serde_json::Value::is_string)); + assert_eq!(slots["search"], "search-model"); + + // The payload is a role mapping, never a model record: no credential, + // endpoint, or provider field may appear anywhere in it. + let body = serde_json::to_string(&response).expect("serialize device defaults response"); + for forbidden in [ + "api_key", + "apikey", + "secret", + "token", + "credential", + "password", + "base_url", + "provider", + ] { + assert!( + !body.contains(forbidden), + "device model defaults response must not carry {forbidden}: {body}" + ); + } +} + +fn device_default_known_model(model_id: &str) -> bool { + matches!(model_id, "text-1" | "vision-1") +} + +#[test] +fn device_model_default_slots_reject_unknown_and_semantic_ids() { + assert_eq!( + resolve_remote_model_default_slot("primary", None, device_default_known_model) + .expect("a cleared slot is valid"), + None + ); + assert_eq!( + resolve_remote_model_default_slot( + "primary", + Some(" text-1 "), + device_default_known_model + ) + .expect("a catalog model is valid"), + Some("text-1".to_string()) + ); + assert_eq!( + resolve_remote_model_default_slot("fast", Some("model-gone"), device_default_known_model) + .expect_err("unknown model ids are rejected"), + "Unknown model selection: model-gone" + ); + assert_eq!( + resolve_remote_model_default_slot( + "image_understanding", + Some("model-gone"), + device_default_known_model + ) + .expect_err("unknown model ids are rejected"), + "Unknown model selection: model-gone" + ); + // The semantic selectors name a session role, never a device default value. + for semantic in ["primary", "fast", "auto", "default"] { + assert!( + resolve_remote_model_default_slot("fast", Some(semantic), device_default_known_model) + .is_err(), + "{semantic} must not be accepted as a device default model id" + ); + } + assert_eq!( + resolve_remote_model_default_slot("primary", Some(" "), device_default_known_model) + .expect_err("an empty model id is not a cleared slot"), + "primary model id is required" + ); +} + +#[test] +fn device_model_defaults_capability_is_advertised_by_the_host() { + assert_eq!( + REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1, + "device_model_defaults_v1" + ); + let RemoteResponse::WorkspaceInfo { capabilities, .. } = remote_workspace_info_response(None) + else { + panic!("workspace info response"); + }; + assert!(capabilities.contains(&REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1.to_string())); +}