@datalayer/agent-runtimes 1.3.79 → 1.3.81
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/lib/chat/assistant/AssistantStage.d.ts +9 -4
- package/lib/chat/assistant/AssistantStage.js +18 -6
- package/lib/chat/assistant/SpriteCharacter.d.ts +25 -3
- package/lib/chat/assistant/SpriteCharacter.js +68 -3
- package/lib/chat/assistant/formats/acf.d.ts +56 -0
- package/lib/chat/assistant/formats/acf.js +317 -0
- package/lib/chat/assistant/formats/acs.d.ts +85 -7
- package/lib/chat/assistant/formats/acs.js +111 -39
- package/lib/chat/assistant/formats/files.d.ts +6 -2
- package/lib/chat/assistant/formats/files.js +18 -4
- package/lib/chat/assistant/formats/index.d.ts +6 -3
- package/lib/chat/assistant/formats/index.js +4 -2
- package/lib/chat/assistant/formats/types.d.ts +14 -0
- package/lib/chat/assistant/state.d.ts +2 -0
- package/lib/chat/assistant/state.js +9 -0
- package/lib/chat/base/ChatBase.js +7 -0
- package/lib/chat/base/loopStep.d.ts +40 -0
- package/lib/chat/base/loopStep.js +73 -0
- package/lib/loop/apps/appspec.js +32 -2
- package/lib/loop/apps/checks.js +53 -0
- package/lib/loop/apps/hostTools.d.ts +17 -0
- package/lib/loop/apps/hostTools.js +20 -0
- package/lib/loop/apps/rules.d.ts +22 -1
- package/lib/loop/apps/rules.js +49 -1
- package/lib/loop/embed/AppEmbed.d.ts +57 -2
- package/lib/loop/embed/AppEmbed.js +71 -8
- package/lib/loop/embed/element.js +40 -1
- package/lib/loop/embed/hostBridge.d.ts +79 -0
- package/lib/loop/embed/hostBridge.js +104 -0
- package/lib/loop/index.d.ts +2 -1
- package/lib/loop/index.js +3 -1
- package/lib/loop/plugins/a2ui-surface/SurfaceToolResult.d.ts +5 -2
- package/lib/loop/plugins/a2ui-surface/SurfaceToolResult.js +7 -1
- package/lib/loop/plugins/a2ui-surface/index.d.ts +1 -1
- package/lib/loop/plugins/a2ui-surface/index.js +4 -4
- package/lib/loop/plugins/a2ui-surface/toolResult.d.ts +27 -0
- package/lib/loop/plugins/a2ui-surface/toolResult.js +22 -0
- package/lib/specs/agents/agents.js +6 -25
- package/lib/specs/apps.js +22 -8
- package/lib/specs/appspecSchema.js +67 -0
- package/lib/types/agentspecs.d.ts +28 -0
- package/package.json +1 -1
- package/scripts/codegen/generate_apps.py +7 -0
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* (`AssistantContextMenu`): what the host passes applies — *Inspect the
|
|
14
14
|
* agent…* (the Agent Inspector, in a dialog loaded then), the conversation,
|
|
15
15
|
* the balloon's display, the suggestions, *Stop*, a new conversation,
|
|
16
|
-
* another character, its voice,
|
|
16
|
+
* another character, its voice, a brought character's sounds (off until
|
|
17
|
+
* *Play its sounds*), send it away, its place, about it — and
|
|
17
18
|
* what a host or a plugin adds (`contextMenu`).
|
|
18
19
|
*
|
|
19
20
|
* Given suggestions, its balloon offers them while it waits for a question
|
|
@@ -36,7 +37,7 @@ import type { AssistantCharacterData } from './formats/types';
|
|
|
36
37
|
import type { DecisionAsker } from './decisions';
|
|
37
38
|
import { type BalloonDisplay, type BalloonToolLine } from './toolLine';
|
|
38
39
|
import type { DisplayItem } from '../../types/chat';
|
|
39
|
-
import { type AssistantAway, type AssistantState, type BalloonApproval } from './state';
|
|
40
|
+
import { mouthOpening, type AssistantAway, type AssistantState, type BalloonApproval } from './state';
|
|
40
41
|
/** What the assistant's *About* says. */
|
|
41
42
|
export type AssistantAbout = {
|
|
42
43
|
/** The agent's name, or its application's. */
|
|
@@ -90,6 +91,11 @@ export declare function assistantMenuItems(options: {
|
|
|
90
91
|
muted: boolean;
|
|
91
92
|
onToggle: () => void;
|
|
92
93
|
};
|
|
94
|
+
/** The sounds of a character read from a file (T-26): off unless asked. */
|
|
95
|
+
characterSounds?: {
|
|
96
|
+
on: boolean;
|
|
97
|
+
onToggle: () => void;
|
|
98
|
+
};
|
|
93
99
|
onResetPosition?: () => void;
|
|
94
100
|
about?: () => void;
|
|
95
101
|
extra?: readonly AssistantMenuItem[];
|
|
@@ -286,8 +292,7 @@ export interface AssistantStageProps {
|
|
|
286
292
|
*/
|
|
287
293
|
label?: string;
|
|
288
294
|
}
|
|
289
|
-
|
|
290
|
-
export declare function mouthOpening(level: number): 0 | 1 | 2 | 3;
|
|
295
|
+
export { mouthOpening };
|
|
291
296
|
/**
|
|
292
297
|
* Moves the mouth with the voice: the opening is written on the stage as
|
|
293
298
|
* `data-assistant-mouth`, each frame, without drawing the character again.
|
|
@@ -11,7 +11,7 @@ import { AssistantContextMenu, LONG_PRESS_MS, opensContextMenu, } from './Assist
|
|
|
11
11
|
import { BalloonExpandContext, ExpandedVisual, focusExpanded, } from './BalloonVisual';
|
|
12
12
|
import { SpriteCharacter } from './SpriteCharacter';
|
|
13
13
|
import { conversationCount, } from './toolLine';
|
|
14
|
-
import { ASSISTANT_OBSTACLES, POINTER_CALM_MS, boxesMeet, pointerNear, } from './state';
|
|
14
|
+
import { ASSISTANT_OBSTACLES, POINTER_CALM_MS, mouthOpening, boxesMeet, pointerNear, } from './state';
|
|
15
15
|
/** The motions, by the state the stage is in. */
|
|
16
16
|
const MOTIONS = {
|
|
17
17
|
'@keyframes assistantFloat': {
|
|
@@ -176,6 +176,14 @@ export function assistantMenuItems(options) {
|
|
|
176
176
|
onSelect: options.speech.onToggle,
|
|
177
177
|
});
|
|
178
178
|
}
|
|
179
|
+
if (options.characterSounds) {
|
|
180
|
+
items.push({
|
|
181
|
+
id: 'character-sounds',
|
|
182
|
+
label: options.characterSounds.on ? 'Mute its sounds' : 'Play its sounds',
|
|
183
|
+
icon: options.characterSounds.on ? MuteIcon : UnmuteIcon,
|
|
184
|
+
onSelect: options.characterSounds.onToggle,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
179
187
|
if (options.onResetPosition) {
|
|
180
188
|
items.push({
|
|
181
189
|
id: 'reset-position',
|
|
@@ -327,10 +335,7 @@ export function useKeepClear(stageRef, own, paused) {
|
|
|
327
335
|
}, [stageRef, own, paused]);
|
|
328
336
|
return obstructed ? 'obstacle' : pointerBusy ? 'pointer' : undefined;
|
|
329
337
|
}
|
|
330
|
-
|
|
331
|
-
export function mouthOpening(level) {
|
|
332
|
-
return level < 0.08 ? 0 : level < 0.3 ? 1 : level < 0.6 ? 2 : 3;
|
|
333
|
-
}
|
|
338
|
+
export { mouthOpening };
|
|
334
339
|
/**
|
|
335
340
|
* Moves the mouth with the voice: the opening is written on the stage as
|
|
336
341
|
* `data-assistant-mouth`, each frame, without drawing the character again.
|
|
@@ -385,6 +390,10 @@ export function AssistantStage({ character, state, size = 88, place, stageRef, o
|
|
|
385
390
|
const characterRef = useRef(null);
|
|
386
391
|
const longPress = useRef(undefined);
|
|
387
392
|
const closeMenu = useCallback(() => setMenuAt(null), []);
|
|
393
|
+
// A character read from a file plays its sounds only once asked (T-26).
|
|
394
|
+
const [soundsOn, setSoundsOn] = useState(false);
|
|
395
|
+
const hasSounds = !shipped &&
|
|
396
|
+
Object.keys(character.sounds ?? {}).length > 0;
|
|
388
397
|
useEffect(() => () => clearTimeout(longPress.current), []);
|
|
389
398
|
// Where the press began: a press that moves is a drag, not a click.
|
|
390
399
|
const pressedAt = useRef(null);
|
|
@@ -423,6 +432,9 @@ export function AssistantStage({ character, state, size = 88, place, stageRef, o
|
|
|
423
432
|
onClear,
|
|
424
433
|
onChangeCharacter,
|
|
425
434
|
speech,
|
|
435
|
+
characterSounds: hasSounds
|
|
436
|
+
? { on: soundsOn, onToggle: () => setSoundsOn(on => !on) }
|
|
437
|
+
: undefined,
|
|
426
438
|
onResetPosition,
|
|
427
439
|
about: about ? () => setTelling(true) : undefined,
|
|
428
440
|
extra: contextMenu,
|
|
@@ -627,7 +639,7 @@ export function AssistantStage({ character, state, size = 88, place, stageRef, o
|
|
|
627
639
|
overflow: 'hidden',
|
|
628
640
|
clip: 'rect(0 0 0 0)',
|
|
629
641
|
whiteSpace: 'nowrap',
|
|
630
|
-
}, children: `Its character: ${characterName}` })), shipped ? (_jsx(shipped.Drawing, { size: size, mode: colorMode })) : (_jsx(SpriteCharacter, { character: character, state: state, size: size }))] }), (hovered || menuOpen) && !aside && state !== 'goodbye' && (_jsx(Box, { sx: { position: 'absolute', top: '-6px', right: '-6px' }, children: _jsxs(ActionMenu, { open: menuOpen, onOpenChange: setMenuOpen, children: [_jsx(ActionMenu.Anchor, { children: _jsx(IconButton, { icon: XIcon, "aria-label": `Send ${name} away`, size: "small", variant: "invisible", "data-assistant-dismiss": "", sx: {
|
|
642
|
+
}, children: `Its character: ${characterName}` })), shipped ? (_jsx(shipped.Drawing, { size: size, mode: colorMode })) : (_jsx(SpriteCharacter, { character: character, state: state, size: size, sounds: soundsOn, mouthLevel: mouthLevel }))] }), (hovered || menuOpen) && !aside && state !== 'goodbye' && (_jsx(Box, { sx: { position: 'absolute', top: '-6px', right: '-6px' }, children: _jsxs(ActionMenu, { open: menuOpen, onOpenChange: setMenuOpen, children: [_jsx(ActionMenu.Anchor, { children: _jsx(IconButton, { icon: XIcon, "aria-label": `Send ${name} away`, size: "small", variant: "invisible", "data-assistant-dismiss": "", sx: {
|
|
631
643
|
bg: 'canvas.default',
|
|
632
644
|
borderRadius: '50%',
|
|
633
645
|
boxShadow: 'shadow.small',
|
|
@@ -5,11 +5,27 @@
|
|
|
5
5
|
* the animation chosen by the state the stage acts (T-22). Nothing plays for
|
|
6
6
|
* a reader who asks for reduced motion: the first frame of the state stands.
|
|
7
7
|
*
|
|
8
|
+
* While it speaks, a frame that has mouths (Microsoft Agent's overlays) is
|
|
9
|
+
* drawn with one: the one its voice's loudness opens (`mouthLevel`), else
|
|
10
|
+
* one after another as its words come. Its sounds are played only when the
|
|
11
|
+
* person asked for them (`sounds`, off unless said: the stage's menu has
|
|
12
|
+
* *Play its sounds* and *Mute its sounds*).
|
|
13
|
+
*
|
|
8
14
|
* @module chat/assistant/SpriteCharacter
|
|
9
15
|
*/
|
|
10
16
|
import type { JSX } from 'react';
|
|
11
|
-
import type { AssistantCharacterAnimation, AssistantCharacterData } from './formats/types';
|
|
12
|
-
import type
|
|
17
|
+
import type { AssistantCharacterAnimation, AssistantCharacterData, AssistantCharacterFrame, AssistantMouthShape } from './formats/types';
|
|
18
|
+
import { type AssistantState } from './state';
|
|
19
|
+
/** How often the mouth changes while the character speaks, in milliseconds. */
|
|
20
|
+
export declare const MOUTH_STEP_MS = 110;
|
|
21
|
+
/**
|
|
22
|
+
* The mouth a frame is drawn with: among the ones it has, the one the voice's
|
|
23
|
+
* `level` opens (any of the wide ones when wide, by `draw`), else, with no
|
|
24
|
+
* voice, one drawn at random; `undefined` when the frame has none.
|
|
25
|
+
*/
|
|
26
|
+
export declare function mouthFor(frame: Pick<AssistantCharacterFrame, 'mouths'> | undefined, level: number | undefined, draw?: number): AssistantMouthShape | undefined;
|
|
27
|
+
/** Plays a sound of the character, in the page. */
|
|
28
|
+
export declare function playCharacterSound(url: string): void;
|
|
13
29
|
/**
|
|
14
30
|
* The frame after `index`: a branch, when the frame has some and the draw
|
|
15
31
|
* falls in one (weights are percents; what is left over goes on), else the
|
|
@@ -18,9 +34,15 @@ import type { AssistantState } from './state';
|
|
|
18
34
|
export declare function nextFrameIndex(animation: AssistantCharacterAnimation, index: number, draw?: number): number | undefined;
|
|
19
35
|
/** One of the animations offered for a state, drawn at random. */
|
|
20
36
|
export declare function animationFor(character: AssistantCharacterData, state: AssistantState, draw?: number): string;
|
|
21
|
-
export declare function SpriteCharacter({ character, state, size, }: {
|
|
37
|
+
export declare function SpriteCharacter({ character, state, size, sounds, mouthLevel, playSound, }: {
|
|
22
38
|
character: AssistantCharacterData;
|
|
23
39
|
state: AssistantState;
|
|
24
40
|
size: number;
|
|
41
|
+
/** Whether its sounds are played: off unless the person asked. */
|
|
42
|
+
sounds?: boolean;
|
|
43
|
+
/** How loud its voice is now, between 0 and 1, while it is heard. */
|
|
44
|
+
mouthLevel?: () => number;
|
|
45
|
+
/** How a sound is played; the page's `Audio` by default. */
|
|
46
|
+
playSound?: (url: string) => void;
|
|
25
47
|
}): JSX.Element;
|
|
26
48
|
export default SpriteCharacter;
|
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useMemo, useState } from 'react';
|
|
3
3
|
import { stateAnimations } from './formats/stateAnimations';
|
|
4
|
+
import { mouthOpening } from './state';
|
|
5
|
+
/** How often the mouth changes while the character speaks, in milliseconds. */
|
|
6
|
+
export const MOUTH_STEP_MS = 110;
|
|
7
|
+
const WIDE = ['wide1', 'wide2', 'wide3', 'wide4'];
|
|
8
|
+
/** The shapes to try, by opening (shut, a little, half, wide). */
|
|
9
|
+
const BY_OPENING = {
|
|
10
|
+
0: ['closed', 'narrow'],
|
|
11
|
+
1: ['narrow', 'medium', 'closed'],
|
|
12
|
+
2: ['medium', 'narrow', ...WIDE],
|
|
13
|
+
3: [...WIDE, 'medium'],
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The mouth a frame is drawn with: among the ones it has, the one the voice's
|
|
17
|
+
* `level` opens (any of the wide ones when wide, by `draw`), else, with no
|
|
18
|
+
* voice, one drawn at random; `undefined` when the frame has none.
|
|
19
|
+
*/
|
|
20
|
+
export function mouthFor(frame, level, draw = Math.random()) {
|
|
21
|
+
const has = Object.keys(frame?.mouths ?? {});
|
|
22
|
+
if (has.length === 0) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
if (level === undefined) {
|
|
26
|
+
return has[Math.min(has.length - 1, Math.floor(draw * has.length))];
|
|
27
|
+
}
|
|
28
|
+
const wanted = BY_OPENING[mouthOpening(level)];
|
|
29
|
+
const wide = WIDE.filter(shape => has.includes(shape));
|
|
30
|
+
if (mouthOpening(level) === 3 && wide.length > 0) {
|
|
31
|
+
return wide[Math.min(wide.length - 1, Math.floor(draw * wide.length))];
|
|
32
|
+
}
|
|
33
|
+
return wanted.find(shape => has.includes(shape)) ?? has[0];
|
|
34
|
+
}
|
|
35
|
+
/** Plays a sound of the character, in the page. */
|
|
36
|
+
export function playCharacterSound(url) {
|
|
37
|
+
const audio = new Audio(url);
|
|
38
|
+
// A sound the browser will not play now (the page not yet interacted
|
|
39
|
+
// with) is skipped: the next frame's will be tried.
|
|
40
|
+
Promise.resolve(audio.play()).catch((error) => {
|
|
41
|
+
console.warn(`A sound of the character was not played: ${String(error)}`);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
4
44
|
/**
|
|
5
45
|
* The frame after `index`: a branch, when the frame has some and the draw
|
|
6
46
|
* falls in one (weights are percents; what is left over goes on), else the
|
|
@@ -24,7 +64,7 @@ export function animationFor(character, state, draw = Math.random()) {
|
|
|
24
64
|
}
|
|
25
65
|
const reducedMotion = () => typeof window !== 'undefined' &&
|
|
26
66
|
!!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
27
|
-
export function SpriteCharacter({ character, state, size, }) {
|
|
67
|
+
export function SpriteCharacter({ character, state, size, sounds = false, mouthLevel, playSound = playCharacterSound, }) {
|
|
28
68
|
const [play, setPlay] = useState(() => ({
|
|
29
69
|
name: animationFor(character, state),
|
|
30
70
|
index: 0,
|
|
@@ -49,14 +89,39 @@ export function SpriteCharacter({ character, state, size, }) {
|
|
|
49
89
|
}, Math.max(frame.duration, 16));
|
|
50
90
|
return () => clearTimeout(timer);
|
|
51
91
|
}, [animation, frame, play, character, state, still]);
|
|
92
|
+
// Its sound, when the frame that has one shows and sounds were asked for.
|
|
93
|
+
const soundUrl = sounds && frame?.sound !== undefined
|
|
94
|
+
? character.sounds?.[frame.sound]
|
|
95
|
+
: undefined;
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (soundUrl) {
|
|
98
|
+
playSound(soundUrl);
|
|
99
|
+
}
|
|
100
|
+
// Each time a frame with a sound shows, the same sound again included.
|
|
101
|
+
}, [soundUrl, play, playSound]);
|
|
102
|
+
// While it speaks, the frame's mouths, one after another.
|
|
103
|
+
const speaking = state === 'speaking' && !still && Boolean(frame?.mouths);
|
|
104
|
+
const [mouth, setMouth] = useState();
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (!speaking) {
|
|
107
|
+
setMouth(undefined);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const step = () => setMouth(mouthFor(frame, mouthLevel?.()));
|
|
111
|
+
step();
|
|
112
|
+
const timer = setInterval(step, MOUTH_STEP_MS);
|
|
113
|
+
return () => clearInterval(timer);
|
|
114
|
+
}, [speaking, frame, mouthLevel]);
|
|
115
|
+
const withMouth = speaking && mouth ? frame?.mouths?.[mouth] : undefined;
|
|
116
|
+
const drawn = withMouth ? [withMouth] : (frame?.images ?? []);
|
|
52
117
|
const { width, height } = character.frameSize;
|
|
53
118
|
const scale = size / Math.max(width, height);
|
|
54
|
-
return (_jsx("span", { className: "assistant-sprite", role: "img", "aria-label": character.name, "data-sprite-animation": play.name, style: {
|
|
119
|
+
return (_jsx("span", { className: "assistant-sprite", role: "img", "aria-label": character.name, "data-sprite-animation": play.name, ...(withMouth ? { 'data-sprite-mouth': mouth } : {}), style: {
|
|
55
120
|
position: 'relative',
|
|
56
121
|
display: 'block',
|
|
57
122
|
width: size,
|
|
58
123
|
height: size,
|
|
59
|
-
}, children:
|
|
124
|
+
}, children: drawn.map((image, layer) => (_jsx("span", { "aria-hidden": true, style: {
|
|
60
125
|
position: 'absolute',
|
|
61
126
|
left: (size - width * scale) / 2,
|
|
62
127
|
top: (size - height * scale) / 2,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type AcsFrame, type AcsImage } from './acs';
|
|
2
|
+
import { type AssistantCharacterData } from './types';
|
|
3
|
+
/** An animation the `.acf` names, and the `.aca` file that holds it. */
|
|
4
|
+
export interface AcfAnimationEntry {
|
|
5
|
+
name: string;
|
|
6
|
+
/** The `.aca` file's name, without a folder. */
|
|
7
|
+
file: string;
|
|
8
|
+
returnAnimation: string;
|
|
9
|
+
checksum: number;
|
|
10
|
+
}
|
|
11
|
+
/** An `.acf` read: the character, and where its animations are. */
|
|
12
|
+
export interface AcfFile {
|
|
13
|
+
version: {
|
|
14
|
+
major: number;
|
|
15
|
+
minor: number;
|
|
16
|
+
};
|
|
17
|
+
name: string;
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
transparentIndex: number;
|
|
21
|
+
/** RGBA, one entry per palette index. */
|
|
22
|
+
palette: Uint32Array;
|
|
23
|
+
states: Record<string, string[]>;
|
|
24
|
+
animations: AcfAnimationEntry[];
|
|
25
|
+
}
|
|
26
|
+
/** One `.aca` read: its frames index its own images and sounds. */
|
|
27
|
+
export interface AcaFile {
|
|
28
|
+
version: {
|
|
29
|
+
major: number;
|
|
30
|
+
minor: number;
|
|
31
|
+
};
|
|
32
|
+
checksum: number;
|
|
33
|
+
transition: number;
|
|
34
|
+
frames: AcsFrame[];
|
|
35
|
+
images: AcsImage[];
|
|
36
|
+
sounds: Uint8Array[];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Read an `.acf`: its header, its character data (compressed or not), the
|
|
40
|
+
* animations it names.
|
|
41
|
+
*/
|
|
42
|
+
export declare function parseAcf(buffer: ArrayBuffer): AcfFile;
|
|
43
|
+
/**
|
|
44
|
+
* Read one `.aca`, for a character of `width`×`height`: its sounds, its
|
|
45
|
+
* images (frame-sized), its frames and their mouths. `file` names it in the
|
|
46
|
+
* sentences that refuse it.
|
|
47
|
+
*/
|
|
48
|
+
export declare function parseAca(buffer: ArrayBuffer, width: number, height: number, file: string): AcaFile;
|
|
49
|
+
/**
|
|
50
|
+
* Read a Microsoft Agent character for the web: its `.acf` and the `.aca`
|
|
51
|
+
* files it names, picked together (`acas` by file name, any case). Every
|
|
52
|
+
* animation the `.acf` names has to be there, the one it names (by its
|
|
53
|
+
* checksum); otherwise it is refused in a sentence. Drawn in the page as an
|
|
54
|
+
* `.acs` is: a sprite sheet, its mouths, its sounds, all object URLs.
|
|
55
|
+
*/
|
|
56
|
+
export declare function readAcfCharacter(acf: ArrayBuffer, acas: Readonly<Record<string, ArrayBuffer>>): Promise<AssistantCharacterData>;
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Microsoft Agent's character for the web (LOOP T-26): an `.acf` that holds
|
|
7
|
+
* the character — its size, palette, states, names — and names one `.aca`
|
|
8
|
+
* file per animation, each holding that animation's frames, images, sounds
|
|
9
|
+
* and mouths. Read in the page after Lebeau's public description (MSAgent
|
|
10
|
+
* Character Data Specification 1.3, "ACF Format", "ACA Format"), from the
|
|
11
|
+
* files a person picked together; nothing is fetched and nothing is sent.
|
|
12
|
+
*
|
|
13
|
+
* An `.aca` frame is one image, the frame's images composited when the
|
|
14
|
+
* file was compiled; its mouths are images of their own, drawn over the
|
|
15
|
+
* frame's image or over a base image that stands for the frame without its
|
|
16
|
+
* top image.
|
|
17
|
+
*
|
|
18
|
+
* Checked against hand-built files only, as the `.acs` reader was: no
|
|
19
|
+
* character of Microsoft's is in the repository.
|
|
20
|
+
*
|
|
21
|
+
* @module chat/assistant/formats/acf
|
|
22
|
+
*/
|
|
23
|
+
import { decompressAgentData } from './agentCompression';
|
|
24
|
+
import { ACF_SIGNATURE, AgentReader, drawAgentCharacter, } from './acs';
|
|
25
|
+
import { AssistantCharacterFormatError, } from './types';
|
|
26
|
+
/** No image, or no sound, in an `.aca` frame. */
|
|
27
|
+
const NONE = 0xffff;
|
|
28
|
+
/** A bottom-up 8-bit bitmap, its rows padded to four bytes, top row first. */
|
|
29
|
+
function topDown(bitmap, width, height) {
|
|
30
|
+
const stride = (width + 3) & ~3;
|
|
31
|
+
const pixels = new Uint8Array(width * height);
|
|
32
|
+
for (let y = 0; y < height; y++) {
|
|
33
|
+
const from = (height - 1 - y) * stride;
|
|
34
|
+
pixels.set(bitmap.subarray(from, from + width), y * width);
|
|
35
|
+
}
|
|
36
|
+
return pixels;
|
|
37
|
+
}
|
|
38
|
+
const strideSize = (width, height) => ((width + 3) & ~3) * height;
|
|
39
|
+
/**
|
|
40
|
+
* Read an `.acf`: its header, its character data (compressed or not), the
|
|
41
|
+
* animations it names.
|
|
42
|
+
*/
|
|
43
|
+
export function parseAcf(buffer) {
|
|
44
|
+
const bytes = new Uint8Array(buffer);
|
|
45
|
+
if (bytes.length < 12) {
|
|
46
|
+
throw new AssistantCharacterFormatError(`This is not a Microsoft Agent .acf: the file is ${bytes.length} bytes, shorter than its header.`);
|
|
47
|
+
}
|
|
48
|
+
const header = new AgentReader(bytes, '.acf', false);
|
|
49
|
+
const signature = header.u32('the signature');
|
|
50
|
+
if (signature !== ACF_SIGNATURE) {
|
|
51
|
+
throw new AssistantCharacterFormatError(`This is not a Microsoft Agent .acf: it starts with 0x${signature.toString(16).padStart(8, '0')}, not 0xabcdabc4.`);
|
|
52
|
+
}
|
|
53
|
+
const size = header.u32('the header');
|
|
54
|
+
const compressed = header.u32('the header');
|
|
55
|
+
const data = compressed > 0
|
|
56
|
+
? decompressAgentData(header.take(compressed, 'the character data'), size, 'The character data of this .acf')
|
|
57
|
+
: header.take(size, 'the character data');
|
|
58
|
+
const r = new AgentReader(data, '.acf', false);
|
|
59
|
+
const minor = r.u16('the version');
|
|
60
|
+
const major = r.u16('the version');
|
|
61
|
+
const animations = [];
|
|
62
|
+
const animationCount = r.u16('the animations');
|
|
63
|
+
for (let k = 0; k < animationCount; k++) {
|
|
64
|
+
const name = r.string('an animation name');
|
|
65
|
+
const file = r.string(`the file of ${name}`);
|
|
66
|
+
const returnAnimation = r.string(`the return animation of ${name}`);
|
|
67
|
+
const checksum = r.u32(`the checksum of ${name}`);
|
|
68
|
+
animations.push({ name, file, returnAnimation, checksum });
|
|
69
|
+
}
|
|
70
|
+
if (animations.length === 0) {
|
|
71
|
+
throw new AssistantCharacterFormatError('This .acf has no animations.');
|
|
72
|
+
}
|
|
73
|
+
r.u32('the character style');
|
|
74
|
+
r.skip(16, 'the character id');
|
|
75
|
+
const names = [];
|
|
76
|
+
const nameCount = r.u16('the character names');
|
|
77
|
+
for (let k = 0; k < nameCount; k++) {
|
|
78
|
+
const lang = r.u16('a name language');
|
|
79
|
+
const value = r.string('a character name');
|
|
80
|
+
r.string('a character description');
|
|
81
|
+
r.string('a character extra data');
|
|
82
|
+
names.push({ lang, name: value });
|
|
83
|
+
}
|
|
84
|
+
const width = r.u16('the character width');
|
|
85
|
+
const height = r.u16('the character height');
|
|
86
|
+
const transparentIndex = r.u8('the transparent colour');
|
|
87
|
+
if (width === 0 || height === 0) {
|
|
88
|
+
throw new AssistantCharacterFormatError(`This .acf is damaged: its character is ${width}×${height} pixels.`);
|
|
89
|
+
}
|
|
90
|
+
const colours = r.u32('the colour table');
|
|
91
|
+
if (colours === 0 || colours > 256) {
|
|
92
|
+
throw new AssistantCharacterFormatError(`This .acf is damaged or of an unknown version: its colour table says ${colours} colours, where 1 to 256 are expected.`);
|
|
93
|
+
}
|
|
94
|
+
const palette = new Uint32Array(256);
|
|
95
|
+
const table = r.take(colours * 4, 'the colour table');
|
|
96
|
+
for (let k = 0; k < colours; k++) {
|
|
97
|
+
// RGBQUAD is blue, green, red, reserved; kept as RGBA in little-endian order.
|
|
98
|
+
palette[k] =
|
|
99
|
+
(0xff << 24) |
|
|
100
|
+
(table[k * 4] << 16) |
|
|
101
|
+
(table[k * 4 + 1] << 8) |
|
|
102
|
+
table[k * 4 + 2];
|
|
103
|
+
}
|
|
104
|
+
const states = {};
|
|
105
|
+
const stateCount = r.u16('the states');
|
|
106
|
+
for (let s = 0; s < stateCount; s++) {
|
|
107
|
+
const state = r.string('a state name');
|
|
108
|
+
const count = r.u16('the animations of a state');
|
|
109
|
+
const list = [];
|
|
110
|
+
for (let k = 0; k < count; k++) {
|
|
111
|
+
list.push(r.string('an animation of a state'));
|
|
112
|
+
}
|
|
113
|
+
states[state] = list;
|
|
114
|
+
}
|
|
115
|
+
// English (0x09 primary language) when there is one, else the first.
|
|
116
|
+
const name = (names.find(n => (n.lang & 0x3ff) === 0x09) ?? names[0])?.name ?? '';
|
|
117
|
+
return {
|
|
118
|
+
version: { major, minor },
|
|
119
|
+
name: name.trim() || 'Character',
|
|
120
|
+
width,
|
|
121
|
+
height,
|
|
122
|
+
transparentIndex,
|
|
123
|
+
palette,
|
|
124
|
+
states,
|
|
125
|
+
animations,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** A DATABLOCK: a ULONG size, then the bytes. */
|
|
129
|
+
const dataBlock = (r, what) => r.take(r.u32(what), what);
|
|
130
|
+
/**
|
|
131
|
+
* Read one `.aca`, for a character of `width`×`height`: its sounds, its
|
|
132
|
+
* images (frame-sized), its frames and their mouths. `file` names it in the
|
|
133
|
+
* sentences that refuse it.
|
|
134
|
+
*/
|
|
135
|
+
export function parseAca(buffer, width, height, file) {
|
|
136
|
+
const bytes = new Uint8Array(buffer);
|
|
137
|
+
const header = new AgentReader(bytes, file, false);
|
|
138
|
+
const minor = header.u16('the version');
|
|
139
|
+
const major = header.u16('the version');
|
|
140
|
+
const checksum = header.u32('the checksum');
|
|
141
|
+
const compressed = header.u8('the compression flag') !== 0;
|
|
142
|
+
let data;
|
|
143
|
+
if (compressed) {
|
|
144
|
+
const size = header.u32('the size of the animation');
|
|
145
|
+
const packed = header.u32('the size of the animation');
|
|
146
|
+
data = decompressAgentData(header.take(packed, 'the animation'), size, `The animation in ${file}`);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
data = bytes.subarray(header.pos);
|
|
150
|
+
}
|
|
151
|
+
const r = new AgentReader(data, file, false);
|
|
152
|
+
const sounds = [];
|
|
153
|
+
const soundCount = r.u16('the sounds');
|
|
154
|
+
for (let k = 0; k < soundCount; k++) {
|
|
155
|
+
sounds.push(dataBlock(r, `sound ${k}`));
|
|
156
|
+
}
|
|
157
|
+
const frameSize = strideSize(width, height);
|
|
158
|
+
const images = [];
|
|
159
|
+
const frameImage = (bitmap, what) => {
|
|
160
|
+
if (bitmap.length !== frameSize) {
|
|
161
|
+
throw new AssistantCharacterFormatError(`${file} is not of this character: ${what} holds ${bitmap.length} bytes, where a ${width}×${height} frame takes ${frameSize}.`);
|
|
162
|
+
}
|
|
163
|
+
return { width, height, pixels: topDown(bitmap, width, height) };
|
|
164
|
+
};
|
|
165
|
+
const imageCount = r.u16('the images');
|
|
166
|
+
for (let k = 0; k < imageCount; k++) {
|
|
167
|
+
const size = r.u32(`image ${k}`);
|
|
168
|
+
r.skip(1, `image ${k}`);
|
|
169
|
+
images.push(frameImage(r.take(size, `image ${k}`), `image ${k}`));
|
|
170
|
+
dataBlock(r, `the region of image ${k}`);
|
|
171
|
+
}
|
|
172
|
+
const transition = r.u8('the transition');
|
|
173
|
+
const frames = [];
|
|
174
|
+
const frameCount = r.u16('the frames');
|
|
175
|
+
for (let f = 0; f < frameCount; f++) {
|
|
176
|
+
const where = `frame ${f}`;
|
|
177
|
+
const image = r.u16(where);
|
|
178
|
+
const sound = r.u16(where);
|
|
179
|
+
const duration = r.u16(where);
|
|
180
|
+
r.skip(4, where);
|
|
181
|
+
const exitBranch = r.i16(where);
|
|
182
|
+
const branches = [];
|
|
183
|
+
const branchCount = r.u8(where);
|
|
184
|
+
for (let k = 0; k < branchCount; k++) {
|
|
185
|
+
branches.push({ frameIndex: r.u16(where), probability: r.u16(where) });
|
|
186
|
+
}
|
|
187
|
+
const overlays = [];
|
|
188
|
+
const overlayCount = r.u8(where);
|
|
189
|
+
for (let k = 0; k < overlayCount; k++) {
|
|
190
|
+
const what = `a mouth of frame ${f}`;
|
|
191
|
+
const replaceTop = r.u8(what) !== 0;
|
|
192
|
+
let base;
|
|
193
|
+
if (replaceTop) {
|
|
194
|
+
// The frame without its top image, which the mouth is drawn over.
|
|
195
|
+
images.push(frameImage(dataBlock(r, what), what));
|
|
196
|
+
base = images.length - 1;
|
|
197
|
+
dataBlock(r, what);
|
|
198
|
+
}
|
|
199
|
+
const type = r.u8(what);
|
|
200
|
+
const size = r.u32(what);
|
|
201
|
+
r.skip(1, what);
|
|
202
|
+
const hasRegion = r.u8(what) !== 0;
|
|
203
|
+
const x = r.i16(what);
|
|
204
|
+
const y = r.i16(what);
|
|
205
|
+
const halfWidth = r.u16(what);
|
|
206
|
+
const halfHeight = r.u16(what);
|
|
207
|
+
const bitmap = r.take(size, what);
|
|
208
|
+
if (hasRegion)
|
|
209
|
+
dataBlock(r, what);
|
|
210
|
+
// Its size is said halved: the bytes say which it is.
|
|
211
|
+
const [w, h] = strideSize(halfWidth * 2, halfHeight * 2) === size
|
|
212
|
+
? [halfWidth * 2, halfHeight * 2]
|
|
213
|
+
: strideSize(halfWidth, halfHeight) === size
|
|
214
|
+
? [halfWidth, halfHeight]
|
|
215
|
+
: [0, 0];
|
|
216
|
+
if (w === 0 || h === 0) {
|
|
217
|
+
throw new AssistantCharacterFormatError(`${file} is damaged: ${what} holds ${size} bytes, which an image of ${halfWidth * 2}×${halfHeight * 2} pixels does not.`);
|
|
218
|
+
}
|
|
219
|
+
images.push({ width: w, height: h, pixels: topDown(bitmap, w, h) });
|
|
220
|
+
overlays.push({
|
|
221
|
+
type,
|
|
222
|
+
replaceTop,
|
|
223
|
+
image: images.length - 1,
|
|
224
|
+
x,
|
|
225
|
+
y,
|
|
226
|
+
...(base !== undefined ? { base: [{ image: base, x: 0, y: 0 }] } : {}),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (image !== NONE && image >= imageCount) {
|
|
230
|
+
throw new AssistantCharacterFormatError(`${file} is damaged: frame ${f} draws image ${image}, and there are ${imageCount}.`);
|
|
231
|
+
}
|
|
232
|
+
if (sound !== NONE && sound >= soundCount) {
|
|
233
|
+
throw new AssistantCharacterFormatError(`${file} is damaged: frame ${f} plays sound ${sound}, and there are ${soundCount}.`);
|
|
234
|
+
}
|
|
235
|
+
frames.push({
|
|
236
|
+
images: image === NONE ? [] : [{ image, x: 0, y: 0 }],
|
|
237
|
+
sound: sound === NONE ? -1 : sound,
|
|
238
|
+
duration,
|
|
239
|
+
exitBranch,
|
|
240
|
+
branches,
|
|
241
|
+
overlays,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
frames.forEach((frame, f) => {
|
|
245
|
+
for (const branch of frame.branches) {
|
|
246
|
+
if (branch.frameIndex >= frames.length) {
|
|
247
|
+
throw new AssistantCharacterFormatError(`${file} is damaged: frame ${f} branches to frame ${branch.frameIndex}, and there are ${frames.length}.`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
return {
|
|
252
|
+
version: { major, minor },
|
|
253
|
+
checksum,
|
|
254
|
+
transition,
|
|
255
|
+
frames,
|
|
256
|
+
images,
|
|
257
|
+
sounds,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Read a Microsoft Agent character for the web: its `.acf` and the `.aca`
|
|
262
|
+
* files it names, picked together (`acas` by file name, any case). Every
|
|
263
|
+
* animation the `.acf` names has to be there, the one it names (by its
|
|
264
|
+
* checksum); otherwise it is refused in a sentence. Drawn in the page as an
|
|
265
|
+
* `.acs` is: a sprite sheet, its mouths, its sounds, all object URLs.
|
|
266
|
+
*/
|
|
267
|
+
export async function readAcfCharacter(acf, acas) {
|
|
268
|
+
const file = parseAcf(acf);
|
|
269
|
+
const byName = new Map(Object.entries(acas).map(([name, buffer]) => [name.toLowerCase(), buffer]));
|
|
270
|
+
const missing = file.animations.filter(entry => !byName.has(entry.file.toLowerCase()));
|
|
271
|
+
if (missing.length > 0) {
|
|
272
|
+
const named = missing
|
|
273
|
+
.slice(0, 3)
|
|
274
|
+
.map(entry => entry.file)
|
|
275
|
+
.join(', ');
|
|
276
|
+
throw new AssistantCharacterFormatError(`This .acf names ${missing.length} animation file${missing.length === 1 ? '' : 's'} that ${missing.length === 1 ? 'was' : 'were'} not picked (${named}${missing.length > 3 ? '…' : ''}): pick the .acf with every .aca beside it.`);
|
|
277
|
+
}
|
|
278
|
+
const images = [];
|
|
279
|
+
const sounds = [];
|
|
280
|
+
const animations = file.animations.map(entry => {
|
|
281
|
+
const aca = parseAca(byName.get(entry.file.toLowerCase()), file.width, file.height, entry.file);
|
|
282
|
+
if (aca.checksum !== entry.checksum) {
|
|
283
|
+
throw new AssistantCharacterFormatError(`${entry.file} is not the one this .acf names for ${entry.name}: their checksums differ.`);
|
|
284
|
+
}
|
|
285
|
+
const imageBase = images.length;
|
|
286
|
+
const soundBase = sounds.length;
|
|
287
|
+
images.push(...aca.images);
|
|
288
|
+
sounds.push(...aca.sounds);
|
|
289
|
+
const shift = (list) => list.map(i => ({ ...i, image: i.image + imageBase }));
|
|
290
|
+
return {
|
|
291
|
+
name: entry.name,
|
|
292
|
+
transition: aca.transition,
|
|
293
|
+
returnAnimation: entry.returnAnimation,
|
|
294
|
+
frames: aca.frames.map(frame => ({
|
|
295
|
+
...frame,
|
|
296
|
+
images: shift(frame.images),
|
|
297
|
+
sound: frame.sound < 0 ? -1 : frame.sound + soundBase,
|
|
298
|
+
overlays: frame.overlays.map(overlay => ({
|
|
299
|
+
...overlay,
|
|
300
|
+
image: overlay.image + imageBase,
|
|
301
|
+
...(overlay.base ? { base: shift(overlay.base) } : {}),
|
|
302
|
+
})),
|
|
303
|
+
})),
|
|
304
|
+
};
|
|
305
|
+
});
|
|
306
|
+
return drawAgentCharacter({
|
|
307
|
+
name: file.name,
|
|
308
|
+
width: file.width,
|
|
309
|
+
height: file.height,
|
|
310
|
+
transparentIndex: file.transparentIndex,
|
|
311
|
+
palette: file.palette,
|
|
312
|
+
states: file.states,
|
|
313
|
+
animations,
|
|
314
|
+
decode: index => images[index],
|
|
315
|
+
sound: index => sounds[index],
|
|
316
|
+
});
|
|
317
|
+
}
|