@glassly/engine 0.3.5 → 0.3.6-dev.846
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/engine.d.ts +11 -1
- package/build/engine.d.ts.map +1 -1
- package/build/engine.js +13 -1
- package/build/engine.js.map +1 -1
- package/build/facades/ai.d.ts +3 -3
- package/build/facades/ai.d.ts.map +1 -1
- package/build/facades/ai.js +3 -3
- package/build/facades/ai.js.map +1 -1
- package/build/facades/glasses.d.ts +5 -1
- package/build/facades/glasses.d.ts.map +1 -1
- package/build/facades/glasses.js +39 -30
- package/build/facades/glasses.js.map +1 -1
- package/build/facades/memory.d.ts +10 -2
- package/build/facades/memory.d.ts.map +1 -1
- package/build/facades/memory.js +10 -0
- package/build/facades/memory.js.map +1 -1
- package/build/facades/miniapps.d.ts +2 -0
- package/build/facades/miniapps.d.ts.map +1 -1
- package/build/facades/miniapps.js +2 -0
- package/build/facades/miniapps.js.map +1 -1
- package/build/facades/pairing.d.ts.map +1 -1
- package/build/facades/pairing.js +3 -2
- package/build/facades/pairing.js.map +1 -1
- package/build/facades/providerKeys.d.ts +6 -2
- package/build/facades/providerKeys.d.ts.map +1 -1
- package/build/facades/providerKeys.js +6 -2
- package/build/facades/providerKeys.js.map +1 -1
- package/build/generated/releaseMetadata.js +4 -4
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/services/AiTierResolution.d.ts +3 -2
- package/build/services/AiTierResolution.d.ts.map +1 -1
- package/build/services/AiTierResolution.js +7 -2
- package/build/services/AiTierResolution.js.map +1 -1
- package/build/services/AppRegistry.d.ts +4 -1
- package/build/services/AppRegistry.d.ts.map +1 -1
- package/build/services/AppRegistry.js +16 -2
- package/build/services/AppRegistry.js.map +1 -1
- package/build/services/BlobStore.js +3 -3
- package/build/services/BlobStore.js.map +1 -1
- package/build/services/BluetoothRestoreLaunch.d.ts +8 -0
- package/build/services/BluetoothRestoreLaunch.d.ts.map +1 -0
- package/build/services/BluetoothRestoreLaunch.js +34 -0
- package/build/services/BluetoothRestoreLaunch.js.map +1 -0
- package/build/services/CloudClientService.d.ts +4 -4
- package/build/services/ForegroundLocationPermission.d.ts +1 -1
- package/build/services/ForegroundLocationPermission.d.ts.map +1 -1
- package/build/services/ForegroundLocationPermission.js.map +1 -1
- package/build/services/GestureDotsService.d.ts.map +1 -1
- package/build/services/GestureDotsService.js +2 -1
- package/build/services/GestureDotsService.js.map +1 -1
- package/build/services/GlassesTouchInput.d.ts +6 -4
- package/build/services/GlassesTouchInput.d.ts.map +1 -1
- package/build/services/GlassesTouchInput.js +81 -41
- package/build/services/GlassesTouchInput.js.map +1 -1
- package/build/services/LocalDisplayManager.d.ts +12 -4
- package/build/services/LocalDisplayManager.d.ts.map +1 -1
- package/build/services/LocalDisplayManager.js +21 -7
- package/build/services/LocalDisplayManager.js.map +1 -1
- package/build/services/LocalLlmModelManager.d.ts +3 -1
- package/build/services/LocalLlmModelManager.d.ts.map +1 -1
- package/build/services/LocalLlmModelManager.js +13 -5
- package/build/services/LocalLlmModelManager.js.map +1 -1
- package/build/services/LocalLlmPrompt.d.ts +10 -9
- package/build/services/LocalLlmPrompt.d.ts.map +1 -1
- package/build/services/LocalLlmPrompt.js +43 -32
- package/build/services/LocalLlmPrompt.js.map +1 -1
- package/build/services/LocalLlmProvider.d.ts +1 -0
- package/build/services/LocalLlmProvider.d.ts.map +1 -1
- package/build/services/LocalLlmProvider.js +62 -22
- package/build/services/LocalLlmProvider.js.map +1 -1
- package/build/services/LocalMiniappRuntime.d.ts +28 -6
- package/build/services/LocalMiniappRuntime.d.ts.map +1 -1
- package/build/services/LocalMiniappRuntime.js +190 -148
- package/build/services/LocalMiniappRuntime.js.map +1 -1
- package/build/services/MemoryStore.d.ts +13 -1
- package/build/services/MemoryStore.d.ts.map +1 -1
- package/build/services/MemoryStore.js +98 -1
- package/build/services/MemoryStore.js.map +1 -1
- package/build/services/MiniappExitGesture.d.ts +6 -3
- package/build/services/MiniappExitGesture.d.ts.map +1 -1
- package/build/services/MiniappExitGesture.js +4 -4
- package/build/services/MiniappExitGesture.js.map +1 -1
- package/build/services/MiniappWidgetMode.d.ts +6 -4
- package/build/services/MiniappWidgetMode.d.ts.map +1 -1
- package/build/services/MiniappWidgetMode.js +7 -6
- package/build/services/MiniappWidgetMode.js.map +1 -1
- package/build/services/ProviderKeyValidation.d.ts +2 -0
- package/build/services/ProviderKeyValidation.d.ts.map +1 -1
- package/build/services/ProviderKeyValidation.js +4 -0
- package/build/services/ProviderKeyValidation.js.map +1 -1
- package/build/services/SceneRenderer.d.ts.map +1 -1
- package/build/services/SceneRenderer.js +3 -1
- package/build/services/SceneRenderer.js.map +1 -1
- package/build/services/ScreenshotFixture.d.ts +56 -0
- package/build/services/ScreenshotFixture.d.ts.map +1 -0
- package/build/services/ScreenshotFixture.js +72 -0
- package/build/services/ScreenshotFixture.js.map +1 -0
- package/build/services/actionInvoke.d.ts +37 -0
- package/build/services/actionInvoke.d.ts.map +1 -0
- package/build/services/actionInvoke.js +89 -0
- package/build/services/actionInvoke.js.map +1 -0
- package/build/services/appleWatch/AppleWatchLink.d.ts +42 -0
- package/build/services/appleWatch/AppleWatchLink.d.ts.map +1 -0
- package/build/services/appleWatch/AppleWatchLink.js +259 -0
- package/build/services/appleWatch/AppleWatchLink.js.map +1 -0
- package/build/services/controllers.d.ts.map +1 -1
- package/build/services/controllers.js +16 -0
- package/build/services/controllers.js.map +1 -1
- package/build/services/manifestActions.d.ts +4 -2
- package/build/services/manifestActions.d.ts.map +1 -1
- package/build/services/manifestActions.js +9 -3
- package/build/services/manifestActions.js.map +1 -1
- package/build/services/memoryBackupFile.d.ts +9 -0
- package/build/services/memoryBackupFile.d.ts.map +1 -0
- package/build/services/memoryBackupFile.js +47 -0
- package/build/services/memoryBackupFile.js.map +1 -0
- package/build/services/settingsBridge.d.ts +4 -4
- package/build/services/settingsBridge.d.ts.map +1 -1
- package/build/services/settingsBridge.js +9 -4
- package/build/services/settingsBridge.js.map +1 -1
- package/build/services/widgetPlacements.d.ts +8 -7
- package/build/services/widgetPlacements.d.ts.map +1 -1
- package/build/services/widgetPlacements.js +7 -6
- package/build/services/widgetPlacements.js.map +1 -1
- package/build/stores/apps.d.ts +6 -0
- package/build/stores/apps.d.ts.map +1 -1
- package/build/stores/apps.js +29 -12
- package/build/stores/apps.js.map +1 -1
- package/build/stores/bluetoothSettingKeys.d.ts +2 -0
- package/build/stores/bluetoothSettingKeys.d.ts.map +1 -1
- package/build/stores/bluetoothSettingKeys.js +17 -0
- package/build/stores/bluetoothSettingKeys.js.map +1 -1
- package/build/stores/settings.d.ts.map +1 -1
- package/build/stores/settings.js +42 -2
- package/build/stores/settings.js.map +1 -1
- package/build/types/applet.d.ts +5 -1
- package/build/types/applet.d.ts.map +1 -1
- package/build/types/applet.js.map +1 -1
- package/build/types/capabilities/rayneo-io.d.ts.map +1 -1
- package/build/types/capabilities/rayneo-io.js +2 -0
- package/build/types/capabilities/rayneo-io.js.map +1 -1
- package/build/types/enums.d.ts +2 -1
- package/build/types/enums.d.ts.map +1 -1
- package/build/types/enums.js +1 -0
- package/build/types/enums.js.map +1 -1
- package/build/utils/cloudClient/RnUdpAdapter.d.ts.map +1 -1
- package/build/utils/cloudClient/RnUdpAdapter.js +13 -2
- package/build/utils/cloudClient/RnUdpAdapter.js.map +1 -1
- package/build/utils/display/scene/index.d.ts +1 -1
- package/build/utils/display/scene/index.d.ts.map +1 -1
- package/build/utils/display/scene/index.js.map +1 -1
- package/build/utils/display/scene/process.d.ts.map +1 -1
- package/build/utils/display/scene/process.js +20 -1
- package/build/utils/display/scene/process.js.map +1 -1
- package/build/utils/display/scene/types.d.ts +1 -1
- package/build/utils/display/scene/types.d.ts.map +1 -1
- package/build/utils/display/scene/types.js.map +1 -1
- package/build/utils/miniappGlobals.d.ts +6 -0
- package/build/utils/miniappGlobals.d.ts.map +1 -1
- package/build/utils/miniappGlobals.js +28 -4
- package/build/utils/miniappGlobals.js.map +1 -1
- package/package.json +9 -9
- package/src/engine.ts +13 -1
- package/src/facades/ai.ts +3 -3
- package/src/facades/glasses.ts +65 -42
- package/src/facades/memory.ts +11 -2
- package/src/facades/miniapps.ts +3 -0
- package/src/facades/pairing.ts +4 -2
- package/src/facades/providerKeys.ts +6 -1
- package/src/generated/releaseMetadata.ts +4 -4
- package/src/services/AiTierResolution.ts +9 -3
- package/src/services/AppRegistry.ts +18 -3
- package/src/services/BlobStore.ts +3 -3
- package/src/services/BluetoothRestoreLaunch.ts +37 -0
- package/src/services/ForegroundLocationPermission.ts +1 -1
- package/src/services/GestureDotsService.ts +2 -1
- package/src/services/GlassesTouchInput.ts +86 -38
- package/src/services/LocalDisplayManager.ts +22 -8
- package/src/services/LocalLlmModelManager.ts +16 -6
- package/src/services/LocalLlmPrompt.ts +56 -37
- package/src/services/LocalLlmProvider.ts +74 -24
- package/src/services/LocalMiniappRuntime.ts +228 -147
- package/src/services/MemoryStore.ts +111 -2
- package/src/services/MiniappExitGesture.ts +8 -5
- package/src/services/MiniappWidgetMode.ts +9 -7
- package/src/services/ProviderKeyValidation.ts +5 -0
- package/src/services/SceneRenderer.ts +3 -1
- package/src/services/ScreenshotFixture.ts +113 -0
- package/src/services/actionInvoke.ts +105 -0
- package/src/services/appleWatch/AppleWatchLink.ts +297 -0
- package/src/services/controllers.ts +17 -0
- package/src/services/manifestActions.ts +16 -4
- package/src/services/memoryBackupFile.ts +50 -0
- package/src/services/settingsBridge.ts +9 -4
- package/src/services/widgetPlacements.ts +23 -9
- package/src/stores/apps.ts +35 -11
- package/src/stores/bluetoothSettingKeys.ts +20 -0
- package/src/stores/settings.ts +44 -2
- package/src/types/applet.ts +5 -1
- package/src/types/capabilities/rayneo-io.ts +2 -0
- package/src/types/enums.ts +1 -0
- package/src/utils/cloudClient/RnUdpAdapter.ts +13 -1
- package/src/utils/display/scene/index.ts +1 -0
- package/src/utils/display/scene/process.ts +17 -1
- package/src/utils/display/scene/types.ts +1 -0
- package/src/utils/miniappGlobals.ts +30 -4
|
@@ -5,6 +5,7 @@ import BluetoothSdk from "@glassly/bluetooth-sdk/internal"
|
|
|
5
5
|
import {useAppStatusStore} from "../stores/apps"
|
|
6
6
|
import {useGlassesStore} from "../stores/glasses"
|
|
7
7
|
import {BgTimer} from "../utils/timers"
|
|
8
|
+
import {hasInput2d} from "./controllers"
|
|
8
9
|
import dashboardOpenTrace from "./debug/DashboardOpenTrace"
|
|
9
10
|
import gestureDotsService from "./GestureDotsService"
|
|
10
11
|
import {isGlassesConnected} from "./GlassesReadiness"
|
|
@@ -17,7 +18,7 @@ import {pagesEligible} from "./pages/pageEligibility"
|
|
|
17
18
|
|
|
18
19
|
const miniappExitGesture = new MiniappExitGesture({
|
|
19
20
|
getState: () => useAppStatusStore.getState(),
|
|
20
|
-
|
|
21
|
+
getTargetPackageName: () => localDisplayManager.getMiniappToLeave(),
|
|
21
22
|
beginExitNotice: (packageName, text) => localDisplayManager.beginMiniappExit(packageName, text),
|
|
22
23
|
finishExitNotice: (packageName) => localDisplayManager.finishMiniappExit(packageName),
|
|
23
24
|
cancelExitNotice: (packageName) => localDisplayManager.cancelMiniappExit(packageName),
|
|
@@ -25,54 +26,104 @@ const miniappExitGesture = new MiniappExitGesture({
|
|
|
25
26
|
clearTimeout: (timerId) => BgTimer.clearTimeout(timerId),
|
|
26
27
|
})
|
|
27
28
|
|
|
28
|
-
//
|
|
29
|
-
// (deviceModel G2) or the ring sends
|
|
30
|
-
// both tag source 2.
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
29
|
+
// The ring reports a slide once per step of travel, whether the CFW forwards
|
|
30
|
+
// it (deviceModel G2) or the ring sends it to the phone itself (deviceModel
|
|
31
|
+
// R1); both tag source 2. One press is one swipe: the stroke's first step goes
|
|
32
|
+
// out as swipe_up / swipe_down, and every step, the first included, as
|
|
33
|
+
// scroll_up / scroll_down with its distance and speed for smooth scrolling. A
|
|
34
|
+
// stroke ends at the ring's next touch_down or tap_end, or after
|
|
35
|
+
// RING_STROKE_IDLE_MS without a step when a report was lost. Reversing within
|
|
36
|
+
// a stroke fires no swipe (a finger rolling back at lift-off). Native receipt
|
|
37
|
+
// timestamps keep strokes apart when JS drains a backlog.
|
|
38
|
+
const RING_STROKE_IDLE_MS = 1000
|
|
39
|
+
/** Per ring, when the open stroke's last step arrived. */
|
|
40
|
+
const ringStrokes = new Map<string, number>()
|
|
41
|
+
|
|
42
|
+
function ringKey(event: TouchEvent): string {
|
|
43
|
+
return event.controllerId ?? `source:${event.source}`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isRingSlideStep(event: TouchEvent): boolean {
|
|
47
|
+
return event.source === 2 && (event.gestureName === "swipe_up" || event.gestureName === "swipe_down")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function opensRingStroke(event: TouchEvent): boolean {
|
|
51
|
+
const last = ringStrokes.get(ringKey(event))
|
|
52
|
+
ringStrokes.set(ringKey(event), event.timestamp)
|
|
40
53
|
const elapsed = last === undefined ? null : event.timestamp - last
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
54
|
+
return elapsed === null || elapsed < 0 || elapsed >= RING_STROKE_IDLE_MS
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function endRingStroke(event: TouchEvent): void {
|
|
58
|
+
if (event.source === 2 && (event.gestureName === "touch_down" || event.gestureName === "tap_end")) {
|
|
59
|
+
ringStrokes.delete(ringKey(event))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The discrete swipe a stroke opens with: the step's travel belongs to scroll_*. */
|
|
64
|
+
function strokeSwipe(step: TouchEvent): TouchEvent {
|
|
65
|
+
const swipe = {...step}
|
|
66
|
+
delete swipe.distance
|
|
67
|
+
delete swipe.speed
|
|
68
|
+
return swipe
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Miniapps' copy of the touch: `input2d: true` when it came from a controller
|
|
73
|
+
* that swipes in two dimensions (the Neural Band), the field absent otherwise,
|
|
74
|
+
* so a 1D swipe from the temples or the ring stays tellable while a band is
|
|
75
|
+
* connected under the same gesture names.
|
|
76
|
+
*/
|
|
77
|
+
function toMiniapps(event: TouchEvent): TouchEvent & {input2d?: true} {
|
|
78
|
+
const id = event.controllerId
|
|
79
|
+
if (id === undefined) return event
|
|
80
|
+
const model = useGlassesStore.getState().controllers?.[id]?.model
|
|
81
|
+
return model !== undefined && hasInput2d(model) ? {...event, input2d: true} : event
|
|
44
82
|
}
|
|
45
83
|
|
|
46
84
|
/**
|
|
47
85
|
* Route both real glasses input and phone mirror controls through the same
|
|
48
86
|
* policy. A claimed gesture goes to the app holding the claim, alone; an
|
|
49
87
|
* unclaimed one goes to every subscriber and then the engine's default. The
|
|
50
|
-
* one rule ahead of the claims: an unclaimed double tap
|
|
51
|
-
*
|
|
52
|
-
*
|
|
88
|
+
* one rule ahead of the claims: an unclaimed double tap leaves the miniapp
|
|
89
|
+
* the wearer is in — the one visibly on the glasses, else the running
|
|
90
|
+
* standard app on an empty screen — whoever else claimed the gesture; the
|
|
91
|
+
* dashboard's claim is for the screen with no app in it. Head position
|
|
53
92
|
* (head_up / head_down) is a gesture like any other: the dashboard claims it
|
|
54
93
|
* while it runs, and an unclaimed head_up on glasses that defer the dashboard
|
|
55
|
-
* to the phone opens the native one, as it would with no app at all.
|
|
94
|
+
* to the phone opens the native one, as it would with no app at all. A ring
|
|
95
|
+
* slide step becomes one swipe per stroke plus a scroll step (see above).
|
|
56
96
|
*/
|
|
57
97
|
export function dispatchTouchEvent(event: TouchEvent): void {
|
|
98
|
+
if (isRingSlideStep(event)) {
|
|
99
|
+
if (opensRingStroke(event)) routeTouchEvent(strokeSwipe(event))
|
|
100
|
+
routeTouchEvent({...event, gestureName: event.gestureName === "swipe_up" ? "scroll_up" : "scroll_down"})
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
endRingStroke(event)
|
|
104
|
+
routeTouchEvent(event)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function routeTouchEvent(event: TouchEvent): void {
|
|
58
108
|
const gesture = event.gestureName
|
|
59
109
|
const claimant = localMiniappRuntime.gestureClaimant(gesture)
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
|
|
110
|
+
const doubleTap = gesture === "double_tap"
|
|
111
|
+
// The Neural Band's middle finger (index 1) shares the tap names. Either
|
|
112
|
+
// finger leaves the app on screen; the firmware dashboard is the index finger's.
|
|
113
|
+
const osDoubleTap = doubleTap && event.index !== 1
|
|
63
114
|
const osHeadUp = gesture === "head_up"
|
|
64
115
|
|
|
65
|
-
if (
|
|
66
|
-
//
|
|
116
|
+
if (doubleTap) {
|
|
117
|
+
// The app being left opts out of OS exit by registering double tap (the
|
|
67
118
|
// terminal's back-to-menu). The latest claimant still receives it. Otherwise
|
|
68
119
|
// the tap means "leave the app". The controller shows the bounded OS
|
|
69
|
-
// notice first, then uses the normal
|
|
70
|
-
//
|
|
71
|
-
const
|
|
120
|
+
// notice first, then uses the normal store stop path (and closes its
|
|
121
|
+
// phone overlay).
|
|
122
|
+
const leaving = localDisplayManager.getMiniappToLeave()
|
|
72
123
|
if (
|
|
73
|
-
|
|
74
|
-
!localMiniappRuntime.hasGestureClaim(
|
|
75
|
-
miniappExitGesture.
|
|
124
|
+
leaving !== null &&
|
|
125
|
+
!localMiniappRuntime.hasGestureClaim(leaving, gesture) &&
|
|
126
|
+
miniappExitGesture.tryExitMiniapp()
|
|
76
127
|
) {
|
|
77
128
|
gestureDotsService.noteGesture(gesture, true, event.index, event.controllerId)
|
|
78
129
|
return
|
|
@@ -86,18 +137,15 @@ export function dispatchTouchEvent(event: TouchEvent): void {
|
|
|
86
137
|
}
|
|
87
138
|
// The press may have started before this app claimed its release edge.
|
|
88
139
|
gestureDotsService.noteGesture(gesture, true, event.index, event.controllerId)
|
|
89
|
-
|
|
90
|
-
localMiniappRuntime.forwardEvent("touch_event", event, undefined, claimant)
|
|
140
|
+
localMiniappRuntime.forwardEvent("touch_event", toMiniapps(event), undefined, claimant)
|
|
91
141
|
return
|
|
92
142
|
}
|
|
93
143
|
|
|
94
144
|
// Gesture-dots debug indicator (any device, gated by its setting). It
|
|
95
|
-
// only paints while the screen is otherwise empty.
|
|
96
|
-
// scroll repeats here so their completion still clears a held dot.
|
|
145
|
+
// only paints while the screen is otherwise empty.
|
|
97
146
|
gestureDotsService.noteGesture(gesture, false, event.index, event.controllerId)
|
|
98
147
|
|
|
99
|
-
|
|
100
|
-
localMiniappRuntime.forwardEvent("touch_event", event)
|
|
148
|
+
localMiniappRuntime.forwardEvent("touch_event", toMiniapps(event))
|
|
101
149
|
|
|
102
150
|
// Nobody claimed a double tap or a head-up: the glasses' native dashboard.
|
|
103
151
|
// On eligible Glassly CFW glasses the dashboard miniapp claims both while it
|
|
@@ -118,7 +166,7 @@ export function dispatchTouchEvent(event: TouchEvent): void {
|
|
|
118
166
|
|
|
119
167
|
export function resetTouchInput(): void {
|
|
120
168
|
miniappExitGesture.reset()
|
|
121
|
-
|
|
169
|
+
ringStrokes.clear()
|
|
122
170
|
}
|
|
123
171
|
|
|
124
172
|
/** Phone mirror input uses the same dashboard/miniapp policy as real glasses. */
|
|
@@ -165,7 +165,7 @@ class LocalDisplayManager {
|
|
|
165
165
|
private overlayOwner: string | null = null
|
|
166
166
|
|
|
167
167
|
/** Miniapp whose visible frame is temporarily covered by an OS exit notice. */
|
|
168
|
-
private exitingApp: {packageName: string; coveredDisplay: ActiveDisplay} | null = null
|
|
168
|
+
private exitingApp: {packageName: string; coveredDisplay: ActiveDisplay | null} | null = null
|
|
169
169
|
|
|
170
170
|
private expiryTimerId: number | null = null
|
|
171
171
|
|
|
@@ -309,16 +309,30 @@ class LocalDisplayManager {
|
|
|
309
309
|
}
|
|
310
310
|
|
|
311
311
|
/**
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
312
|
+
* The miniapp an unclaimed double tap leaves: the one visibly drawing on
|
|
313
|
+
* the main view, else the core (running standard) app on an empty or
|
|
314
|
+
* system-drawn screen — an app that has drawn nothing yet, or whose frame
|
|
315
|
+
* expired, is still the one the wearer is in. Nothing while a page covers
|
|
316
|
+
* the main view: the page's claims own the tap.
|
|
317
|
+
*/
|
|
318
|
+
public getMiniappToLeave(): string | null {
|
|
319
|
+
if (this.overlayOwner !== null) return null
|
|
320
|
+
return this.getVisibleMiniappPackageName() ?? this.coreApp
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Cover the leaving miniapp with a persistent system exit notice. While it
|
|
325
|
+
* is up, miniapp display traffic is held off so a streaming app cannot
|
|
326
|
+
* immediately overwrite the notice. The caller completes the normal stop
|
|
327
|
+
* flow and then calls finishMiniappExit(); onUnmount does the same as a
|
|
316
328
|
* safety net.
|
|
317
329
|
*/
|
|
318
330
|
public beginMiniappExit(packageName: string, text: string): boolean {
|
|
319
331
|
if (this.exitingApp !== null) return this.exitingApp.packageName === packageName
|
|
320
|
-
|
|
321
|
-
|
|
332
|
+
if (this.getMiniappToLeave() !== packageName) return false
|
|
333
|
+
// Only the app's own visible frame comes back on cancel; a system frame
|
|
334
|
+
// or an empty screen does not.
|
|
335
|
+
const coveredDisplay = this.getVisibleMiniappPackageName() === packageName ? this.currentDisplay : null
|
|
322
336
|
|
|
323
337
|
console.log(`${LOG_TAG}: beginMiniappExit(${packageName})`)
|
|
324
338
|
this.exitingApp = {packageName, coveredDisplay}
|
|
@@ -349,7 +363,7 @@ class LocalDisplayManager {
|
|
|
349
363
|
this.exitingApp = null
|
|
350
364
|
|
|
351
365
|
if (this.currentDisplay?.packageName !== SYSTEM_NOTICE_PKG) return
|
|
352
|
-
if (covered.expiresAt === null || covered.expiresAt > this.now()) {
|
|
366
|
+
if (covered && (covered.expiresAt === null || covered.expiresAt > this.now())) {
|
|
353
367
|
this.restoreDisplay(covered)
|
|
354
368
|
} else {
|
|
355
369
|
this.currentDisplay = null
|
|
@@ -24,8 +24,10 @@ export interface LocalLlmModelConfig {
|
|
|
24
24
|
id: LocalLlmModelId
|
|
25
25
|
/** Human label for the Settings dropdown. */
|
|
26
26
|
label: string
|
|
27
|
-
/**
|
|
27
|
+
/** Total download size in bytes (the .pte dominates). */
|
|
28
28
|
sizeBytes: number
|
|
29
|
+
/** Exact size of the published .pte; the availability check compares against it. */
|
|
30
|
+
modelBytes: number
|
|
29
31
|
/** Rough resident memory while loaded; the Settings copy warns above ~half of device RAM. */
|
|
30
32
|
ramBytes: number
|
|
31
33
|
urls: {
|
|
@@ -54,8 +56,10 @@ export interface LocalLlmDownloadStatus {
|
|
|
54
56
|
|
|
55
57
|
// XNNPACK 8da4w exports from software-mansion/react-native-executorch-qwen-3,
|
|
56
58
|
// pinned to the v0.10.0 release tag that react-native-executorch 0.10.x
|
|
57
|
-
// expects.
|
|
59
|
+
// expects. modelBytes are the .pte sizes on the Hub; sizeBytes adds the
|
|
60
|
+
// tokenizer files (~11 MB).
|
|
58
61
|
const QWEN3_BASE = "https://huggingface.co/software-mansion/react-native-executorch-qwen-3/resolve/v0.10.0"
|
|
62
|
+
const TOKENIZER_BYTES = 11_431_080
|
|
59
63
|
const MB = 1024 * 1024
|
|
60
64
|
const GB = 1024 * MB
|
|
61
65
|
|
|
@@ -63,7 +67,8 @@ const MODELS: Record<LocalLlmModelId, LocalLlmModelConfig> = {
|
|
|
63
67
|
"qwen3-0.6b": {
|
|
64
68
|
id: "qwen3-0.6b",
|
|
65
69
|
label: "Qwen3 0.6B",
|
|
66
|
-
sizeBytes:
|
|
70
|
+
sizeBytes: 505_686_400 + TOKENIZER_BYTES,
|
|
71
|
+
modelBytes: 505_686_400,
|
|
67
72
|
ramBytes: 0.9 * GB,
|
|
68
73
|
urls: {
|
|
69
74
|
model: `${QWEN3_BASE}/0_6b/xnnpack/qwen_3_0_6b_xnnpack_8da4w.pte`,
|
|
@@ -74,7 +79,8 @@ const MODELS: Record<LocalLlmModelId, LocalLlmModelConfig> = {
|
|
|
74
79
|
"qwen3-1.7b": {
|
|
75
80
|
id: "qwen3-1.7b",
|
|
76
81
|
label: "Qwen3 1.7B",
|
|
77
|
-
sizeBytes:
|
|
82
|
+
sizeBytes: 1_303_738_496 + TOKENIZER_BYTES,
|
|
83
|
+
modelBytes: 1_303_738_496,
|
|
78
84
|
ramBytes: 1.8 * GB,
|
|
79
85
|
urls: {
|
|
80
86
|
model: `${QWEN3_BASE}/1_7b/xnnpack/qwen_3_1_7b_xnnpack_8da4w.pte`,
|
|
@@ -85,7 +91,8 @@ const MODELS: Record<LocalLlmModelId, LocalLlmModelConfig> = {
|
|
|
85
91
|
"qwen3-4b": {
|
|
86
92
|
id: "qwen3-4b",
|
|
87
93
|
label: "Qwen3 4B",
|
|
88
|
-
sizeBytes:
|
|
94
|
+
sizeBytes: 2_681_646_976 + TOKENIZER_BYTES,
|
|
95
|
+
modelBytes: 2_681_646_976,
|
|
89
96
|
ramBytes: 3.4 * GB,
|
|
90
97
|
urls: {
|
|
91
98
|
model: `${QWEN3_BASE}/4b/xnnpack/qwen_3_4b_xnnpack_8da4w.pte`,
|
|
@@ -161,7 +168,7 @@ class LocalLlmModelManager {
|
|
|
161
168
|
// most of the published size. Downloads land in a .part file and are
|
|
162
169
|
// renamed only once whole, so this only catches external tampering.
|
|
163
170
|
const stat = await RNFS.stat(paths.model)
|
|
164
|
-
return Number(stat.size) >= MODELS[id].
|
|
171
|
+
return Number(stat.size) >= MODELS[id].modelBytes * 0.95
|
|
165
172
|
} catch (error) {
|
|
166
173
|
console.warn("LOCAL_LLM: availability check failed:", error)
|
|
167
174
|
return false
|
|
@@ -243,6 +250,9 @@ class LocalLlmModelManager {
|
|
|
243
250
|
await this.fetchFile(config.urls.model, paths.model, (percent) =>
|
|
244
251
|
this.setStatus({modelId: id, stage: "downloading", percent}),
|
|
245
252
|
)
|
|
253
|
+
if (!(await this.isModelAvailable(id))) {
|
|
254
|
+
throw new Error(`downloaded model is smaller than the expected ${config.modelBytes} bytes`)
|
|
255
|
+
}
|
|
246
256
|
this.setStatus({modelId: id, stage: "complete", percent: 100})
|
|
247
257
|
for (const listener of this.modelReadyListeners) {
|
|
248
258
|
try {
|
|
@@ -85,55 +85,74 @@ export function toTemplateTools(tools?: LlmTool[]): TemplateTool[] | undefined {
|
|
|
85
85
|
}))
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
export function estimateTokens(text: string): number {
|
|
94
|
-
return Math.ceil(text.length / CHARS_PER_TOKEN)
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export function estimateMessageTokens(message: TemplateMessage): number {
|
|
98
|
-
let total = TOKENS_PER_MESSAGE + estimateTokens(message.content)
|
|
99
|
-
if (message.role === "assistant" && message.tool_calls) {
|
|
100
|
-
for (const call of message.tool_calls) total += estimateTokens(JSON.stringify(call.function)) + 8
|
|
101
|
-
}
|
|
102
|
-
return total
|
|
88
|
+
export interface FittedLocalPrompt {
|
|
89
|
+
prompt: string
|
|
90
|
+
promptTokens: number
|
|
91
|
+
truncated: boolean
|
|
103
92
|
}
|
|
104
93
|
|
|
105
|
-
const MIN_TOOL_CONTENT_CHARS = 1_200
|
|
106
|
-
|
|
107
94
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* When that last turn alone still does not fit, tool outputs are cut to
|
|
113
|
-
* their head.
|
|
95
|
+
* Measure the complete rendered prompt with the model's tokenizer, including
|
|
96
|
+
* template framing, system instructions and tool schemas. Drop old turns first,
|
|
97
|
+
* then shorten tool results / message text in the final turn. Never cut template
|
|
98
|
+
* delimiters, system instructions, or structured tool calls to make them fit.
|
|
114
99
|
*/
|
|
115
|
-
export function
|
|
116
|
-
|
|
100
|
+
export async function fitPromptToContext(
|
|
101
|
+
messages: TemplateMessage[],
|
|
102
|
+
budgetTokens: number,
|
|
103
|
+
render: (messages: TemplateMessage[]) => string,
|
|
104
|
+
countTokens: (prompt: string) => Promise<number>,
|
|
105
|
+
): Promise<FittedLocalPrompt> {
|
|
106
|
+
if (!Number.isInteger(budgetTokens) || budgetTokens < 1) {
|
|
107
|
+
throw new Error("On-device model has no room for a prompt after reserving output tokens")
|
|
108
|
+
}
|
|
117
109
|
const system = messages[0]?.role === "system" ? [messages[0]] : []
|
|
118
110
|
const turns: TemplateMessage[][] = []
|
|
119
111
|
for (const message of messages.slice(system.length)) {
|
|
120
112
|
if (message.role === "user" || turns.length === 0) turns.push([message])
|
|
121
113
|
else turns[turns.length - 1]!.push(message)
|
|
122
114
|
}
|
|
123
|
-
|
|
124
|
-
while (turns.length > 1 && total([...system, ...turns.flat()]) > budgetTokens) {
|
|
125
|
-
turns.shift()
|
|
126
|
-
}
|
|
127
|
-
|
|
128
115
|
let fitted = [...system, ...turns.flat()]
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
)
|
|
116
|
+
let truncated = false
|
|
117
|
+
const marker = "\n…[truncated]…\n"
|
|
118
|
+
while (true) {
|
|
119
|
+
const prompt = render(fitted)
|
|
120
|
+
const promptTokens = await countTokens(prompt)
|
|
121
|
+
if (!Number.isInteger(promptTokens) || promptTokens < 1) {
|
|
122
|
+
throw new Error("On-device tokenizer returned an invalid prompt token count")
|
|
123
|
+
}
|
|
124
|
+
if (promptTokens <= budgetTokens) return {prompt, promptTokens, truncated}
|
|
125
|
+
truncated = true
|
|
126
|
+
if (turns.length > 1) {
|
|
127
|
+
turns.shift()
|
|
128
|
+
fitted = [...system, ...turns.flat()]
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
// Preserve the user's question while tool output can still be shortened.
|
|
132
|
+
const candidates = fitted
|
|
133
|
+
.map((message, index) => ({message, index}))
|
|
134
|
+
.filter(({message}) => message.role !== "system" && Array.from(message.content).length > marker.length * 2)
|
|
135
|
+
.sort(
|
|
136
|
+
(a, b) =>
|
|
137
|
+
Number(b.message.role === "tool") - Number(a.message.role === "tool") ||
|
|
138
|
+
b.message.content.length - a.message.content.length,
|
|
139
|
+
)
|
|
140
|
+
const candidate = candidates[0]
|
|
141
|
+
if (!candidate) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`On-device prompt instructions and tool definitions exceed the context budget (${promptTokens} > ${budgetTokens} tokens)`,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
// Keep both ends of long transcripts and questions, without splitting a
|
|
147
|
+
// Unicode code point. Each pass shrinks strictly, then remeasures the whole
|
|
148
|
+
// rendered prompt; character ratios are never used as a safety check.
|
|
149
|
+
const chars = Array.from(candidate.message.content)
|
|
150
|
+
const keep = Math.floor(chars.length / 4)
|
|
151
|
+
fitted[candidate.index] = {
|
|
152
|
+
...candidate.message,
|
|
153
|
+
content: `${chars.slice(0, keep).join("")}${marker}${chars.slice(-keep).join("")}`,
|
|
154
|
+
}
|
|
135
155
|
}
|
|
136
|
-
return fitted
|
|
137
156
|
}
|
|
138
157
|
|
|
139
158
|
/** Remove a leading `<think>…</think>` block (or an unterminated one). */
|
|
@@ -22,9 +22,8 @@ import {BgTimer} from "../utils/timers"
|
|
|
22
22
|
import localLlmModelManager, {type LocalLlmModelId} from "./LocalLlmModelManager"
|
|
23
23
|
import {
|
|
24
24
|
buildTemplateMessages,
|
|
25
|
-
estimateTokens,
|
|
26
25
|
finalizeLocalResult,
|
|
27
|
-
|
|
26
|
+
fitPromptToContext,
|
|
28
27
|
toTemplateTools,
|
|
29
28
|
type LocalCompletion,
|
|
30
29
|
} from "./LocalLlmPrompt"
|
|
@@ -33,6 +32,7 @@ import {SETTINGS, useSettingsStore} from "../stores/settings"
|
|
|
33
32
|
type Rne = typeof import("react-native-executorch")
|
|
34
33
|
// The runner API lives on the `llm` namespace; derive its types from there so
|
|
35
34
|
// the import stays a type-only reference (no native module at load time).
|
|
35
|
+
type Tokenizer = ReturnType<Rne["nlp"]["loadTokenizer"]>
|
|
36
36
|
type LLMRunner = ReturnType<Rne["llm"]["createLLMRunner"]>
|
|
37
37
|
type LLMGenerationStats = ReturnType<LLMRunner["generate"]>
|
|
38
38
|
type LLMGenerationConfig = NonNullable<Parameters<LLMRunner["generate"]>[1]>
|
|
@@ -43,14 +43,23 @@ const LOG_TAG = "LOCAL_LLM"
|
|
|
43
43
|
const IDLE_UNLOAD_MS = 5 * 60_000
|
|
44
44
|
/** Hard stop on one generation — the SDK request times out at 120 s anyway. */
|
|
45
45
|
const GENERATION_DEADLINE_MS = 100_000
|
|
46
|
+
// One generating, one waiting. Assist's proactive insights re-request every
|
|
47
|
+
// time the SDK's 60 s timeout fires while a slow on-device run is still going,
|
|
48
|
+
// and an unbounded queue kept the phone generating for callers that had
|
|
49
|
+
// already given up (one reporter saw 472 s runs and 15 s touch latency).
|
|
50
|
+
const MAX_PENDING_REQUESTS = 2
|
|
51
|
+
// Under the SDK's 60 s request timeout: a request that waited longer has no caller.
|
|
52
|
+
const QUEUE_WAIT_LIMIT_MS = 55_000
|
|
46
53
|
const DEFAULT_MAX_NEW_TOKENS = 512
|
|
47
54
|
const MAX_NEW_TOKENS_CEILING = 1_024
|
|
48
|
-
/**
|
|
55
|
+
/** Reserve space for runner-added BOS/EOS tokens and context boundary bookkeeping. */
|
|
49
56
|
const CONTEXT_MARGIN_TOKENS = 96
|
|
50
57
|
|
|
51
58
|
interface LoadedModel {
|
|
52
59
|
modelId: LocalLlmModelId
|
|
53
60
|
runner: LLMRunner
|
|
61
|
+
tokenizer: Tokenizer
|
|
62
|
+
countTokens: (tokenizer: Tokenizer, prompt: string) => Promise<number>
|
|
54
63
|
template: Template
|
|
55
64
|
stopTokens: string[]
|
|
56
65
|
maxSeqLen: number
|
|
@@ -85,12 +94,18 @@ function generateWorklet(
|
|
|
85
94
|
return {text, stats}
|
|
86
95
|
}
|
|
87
96
|
|
|
97
|
+
function countTokensWorklet(tokenizer: Tokenizer, prompt: string): number {
|
|
98
|
+
"worklet"
|
|
99
|
+
return tokenizer.encode(prompt).length
|
|
100
|
+
}
|
|
101
|
+
|
|
88
102
|
class LocalLlmProvider {
|
|
89
103
|
private static instance: LocalLlmProvider
|
|
90
104
|
|
|
91
105
|
private loaded: LoadedModel | null = null
|
|
92
106
|
private loading: Promise<LoadedModel> | null = null
|
|
93
107
|
private queue: Promise<unknown> = Promise.resolve()
|
|
108
|
+
private pendingCount = 0
|
|
94
109
|
private idleTimer: number | null = null
|
|
95
110
|
private rne: Rne | null = null
|
|
96
111
|
|
|
@@ -120,9 +135,24 @@ class LocalLlmProvider {
|
|
|
120
135
|
|
|
121
136
|
/** Serve one neutral completion on-device. Throws when the model is missing or generation fails. */
|
|
122
137
|
complete(request: LlmCompleteRequest): Promise<LocalLlmCompleteResult> {
|
|
123
|
-
|
|
138
|
+
if (this.pendingCount >= MAX_PENDING_REQUESTS) {
|
|
139
|
+
return Promise.reject(
|
|
140
|
+
new Error(`The on-device model is busy (${this.pendingCount} requests pending); try again shortly.`),
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
this.pendingCount += 1
|
|
144
|
+
const queuedAt = Date.now()
|
|
145
|
+
const run = this.queue.then(() => {
|
|
146
|
+
const waitedMs = Date.now() - queuedAt
|
|
147
|
+
if (waitedMs > QUEUE_WAIT_LIMIT_MS) {
|
|
148
|
+
throw new Error(`The on-device model was busy for ${Math.round(waitedMs / 1000)} s; request dropped.`)
|
|
149
|
+
}
|
|
150
|
+
return this.completeInner(request)
|
|
151
|
+
})
|
|
124
152
|
this.queue = run.catch(() => undefined)
|
|
125
|
-
return run
|
|
153
|
+
return run.finally(() => {
|
|
154
|
+
this.pendingCount -= 1
|
|
155
|
+
})
|
|
126
156
|
}
|
|
127
157
|
|
|
128
158
|
/** Release the resident runner (idle, model switch, memory pressure). Safe to call repeatedly. */
|
|
@@ -150,6 +180,8 @@ class LocalLlmProvider {
|
|
|
150
180
|
loaded.runner.dispose()
|
|
151
181
|
} catch (error) {
|
|
152
182
|
console.warn(`${LOG_TAG}: runner dispose failed:`, error)
|
|
183
|
+
} finally {
|
|
184
|
+
loaded.tokenizer.dispose()
|
|
153
185
|
}
|
|
154
186
|
}
|
|
155
187
|
|
|
@@ -159,21 +191,21 @@ class LocalLlmProvider {
|
|
|
159
191
|
const model = await this.ensureLoaded()
|
|
160
192
|
const jsonMode = request.jsonMode === true
|
|
161
193
|
const tools = toTemplateTools(request.tools)
|
|
162
|
-
const toolTokens = tools ? estimateTokens(JSON.stringify(tools)) + 64 : 0
|
|
163
194
|
|
|
164
195
|
const maxNewTokens = Math.max(
|
|
165
196
|
16,
|
|
166
197
|
Math.min(request.maxTokens ?? DEFAULT_MAX_NEW_TOKENS, MAX_NEW_TOKENS_CEILING, Math.floor(model.maxSeqLen / 4)),
|
|
167
198
|
)
|
|
168
199
|
const promptBudget = model.maxSeqLen - maxNewTokens - CONTEXT_MARGIN_TOKENS
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
messages,
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
200
|
+
const {prompt, promptTokens, truncated} = await fitPromptToContext(
|
|
201
|
+
buildTemplateMessages({...request, jsonMode}),
|
|
202
|
+
promptBudget,
|
|
203
|
+
(messages) => model.template.render({messages, tools, add_generation_prompt: true, enable_thinking: false}),
|
|
204
|
+
(text) => model.countTokens(model.tokenizer, text),
|
|
205
|
+
)
|
|
206
|
+
console.log(
|
|
207
|
+
`${LOG_TAG}: ${model.modelId} prompt=${promptTokens} budget=${promptBudget} context=${model.maxSeqLen} maxNewTokens=${maxNewTokens} truncated=${truncated}`,
|
|
208
|
+
)
|
|
177
209
|
|
|
178
210
|
model.runner.reset(0)
|
|
179
211
|
const deadline = BgTimer.setTimeout(() => {
|
|
@@ -189,6 +221,12 @@ class LocalLlmProvider {
|
|
|
189
221
|
{maxNewTokens, temperature: request.temperature ?? 0.7},
|
|
190
222
|
model.stopTokens,
|
|
191
223
|
)
|
|
224
|
+
} catch (error) {
|
|
225
|
+
console.warn(
|
|
226
|
+
`${LOG_TAG}: generation failed: model=${model.modelId} prompt=${promptTokens} context=${model.maxSeqLen} maxNewTokens=${maxNewTokens}`,
|
|
227
|
+
error,
|
|
228
|
+
)
|
|
229
|
+
throw error
|
|
192
230
|
} finally {
|
|
193
231
|
BgTimer.clearTimeout(deadline)
|
|
194
232
|
}
|
|
@@ -246,16 +284,28 @@ class LocalLlmProvider {
|
|
|
246
284
|
const startedAt = Date.now()
|
|
247
285
|
const createRunner = rne.wrapAsync(rne.llm.createLLMRunner)
|
|
248
286
|
const runner = await createRunner(paths.model, paths.tokenizer, [])
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
287
|
+
let tokenizer: Tokenizer | undefined
|
|
288
|
+
try {
|
|
289
|
+
const maxSeqLen = runner.getKVCacheState().maxSeqLen
|
|
290
|
+
if (!Number.isInteger(maxSeqLen) || maxSeqLen <= CONTEXT_MARGIN_TOKENS + 16) {
|
|
291
|
+
throw new Error(`On-device model ${modelId} reported an invalid context size: ${maxSeqLen}`)
|
|
292
|
+
}
|
|
293
|
+
tokenizer = await rne.wrapAsync(rne.nlp.loadTokenizer)(paths.tokenizer)
|
|
294
|
+
console.log(`${LOG_TAG}: loaded ${modelId} (context ${maxSeqLen}) in ${Date.now() - startedAt} ms`)
|
|
295
|
+
return {
|
|
296
|
+
modelId,
|
|
297
|
+
runner,
|
|
298
|
+
tokenizer,
|
|
299
|
+
countTokens: rne.wrapAsync(countTokensWorklet),
|
|
300
|
+
template,
|
|
301
|
+
stopTokens: [...tokenizerConfig.stopTokens],
|
|
302
|
+
maxSeqLen,
|
|
303
|
+
generate: rne.wrapAsync(generateWorklet),
|
|
304
|
+
}
|
|
305
|
+
} catch (error) {
|
|
306
|
+
tokenizer?.dispose()
|
|
307
|
+
runner.dispose()
|
|
308
|
+
throw error
|
|
259
309
|
}
|
|
260
310
|
}
|
|
261
311
|
|