@ayni.org/ayni-ui-core 0.1.0 → 0.2.0
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/dist/appLinks.d.ts +42 -0
- package/dist/appLinks.js +120 -0
- package/dist/conversationDesign.d.ts +167 -0
- package/dist/conversationDesign.js +125 -0
- package/dist/index.d.ts +26 -2
- package/dist/index.js +32 -9
- package/dist/reducer.d.ts +3 -1
- package/dist/reducer.js +17 -1
- package/dist/types.d.ts +7 -0
- package/package.json +1 -1
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App links (AG-UI profile 3.1 §10, UI-LINK-001..003, D38): HTTPS links on the network's app-link domain, which is
|
|
3
|
+
* the web UI's own host. Parsing reads only the profile's parameters (`q`, a DID, a segment, `to`, `amount`, `memo`,
|
|
4
|
+
* `oob`) and ignores every other one; the UI opens the matching web route in place when one exists and shows
|
|
5
|
+
* "on your phone" with the destination named otherwise. Links navigate, never act.
|
|
6
|
+
*/
|
|
7
|
+
export type AppLinkDestination = {
|
|
8
|
+
path: 'home';
|
|
9
|
+
controller?: string;
|
|
10
|
+
} | {
|
|
11
|
+
path: 'recent';
|
|
12
|
+
segment?: 'feed';
|
|
13
|
+
} | {
|
|
14
|
+
path: 'scan';
|
|
15
|
+
} | {
|
|
16
|
+
path: 'explore';
|
|
17
|
+
q?: string;
|
|
18
|
+
create?: boolean;
|
|
19
|
+
} | {
|
|
20
|
+
path: 'wallet';
|
|
21
|
+
segment?: 'documents' | 'history' | 'card' | 'send';
|
|
22
|
+
id?: string;
|
|
23
|
+
} | {
|
|
24
|
+
path: 'service';
|
|
25
|
+
did: string;
|
|
26
|
+
} | {
|
|
27
|
+
path: 'settings';
|
|
28
|
+
section?: 'backup' | 'devices';
|
|
29
|
+
} | {
|
|
30
|
+
path: 'connect';
|
|
31
|
+
oob?: string;
|
|
32
|
+
} | {
|
|
33
|
+
path: 'unknown';
|
|
34
|
+
};
|
|
35
|
+
/** Whether `href` is an app link of this UI's app-link domain (`origin` is the UI's own origin). */
|
|
36
|
+
export declare function isAppLink(href: string, origin: string): boolean;
|
|
37
|
+
/** The destination an app link names; `unknown` for a path outside §10. Stray parameters change nothing. */
|
|
38
|
+
export declare function parseAppLink(href: string, origin: string): AppLinkDestination;
|
|
39
|
+
/** The web routes of UI-LINK-001; every other destination is shown as "on your phone". */
|
|
40
|
+
export declare function hasWebRoute(destination: AppLinkDestination): boolean;
|
|
41
|
+
/** A stable key for the destination's label (`link.destination.<key>` in the UI's strings). */
|
|
42
|
+
export declare function destinationKey(destination: AppLinkDestination): string;
|
package/dist/appLinks.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App links (AG-UI profile 3.1 §10, UI-LINK-001..003, D38): HTTPS links on the network's app-link domain, which is
|
|
3
|
+
* the web UI's own host. Parsing reads only the profile's parameters (`q`, a DID, a segment, `to`, `amount`, `memo`,
|
|
4
|
+
* `oob`) and ignores every other one; the UI opens the matching web route in place when one exists and shows
|
|
5
|
+
* "on your phone" with the destination named otherwise. Links navigate, never act.
|
|
6
|
+
*/
|
|
7
|
+
/** Whether `href` is an app link of this UI's app-link domain (`origin` is the UI's own origin). */
|
|
8
|
+
export function isAppLink(href, origin) {
|
|
9
|
+
try {
|
|
10
|
+
const url = new URL(href, origin);
|
|
11
|
+
return url.origin === origin && (url.pathname === '/app' || url.pathname.startsWith('/app/'));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** The destination an app link names; `unknown` for a path outside §10. Stray parameters change nothing. */
|
|
18
|
+
export function parseAppLink(href, origin) {
|
|
19
|
+
let url;
|
|
20
|
+
try {
|
|
21
|
+
url = new URL(href, origin);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return { path: 'unknown' };
|
|
25
|
+
}
|
|
26
|
+
const parts = url.pathname
|
|
27
|
+
.replace(/\/+$/, '')
|
|
28
|
+
.split('/')
|
|
29
|
+
.filter(Boolean)
|
|
30
|
+
.map((p) => decodeURIComponent(p));
|
|
31
|
+
if (parts[0] !== 'app')
|
|
32
|
+
return { path: 'unknown' };
|
|
33
|
+
const [, head, second, third] = parts;
|
|
34
|
+
const params = url.searchParams;
|
|
35
|
+
switch (head) {
|
|
36
|
+
case 'home':
|
|
37
|
+
return parts.length <= 3 ? { path: 'home', ...(second ? { controller: second } : {}) } : { path: 'unknown' };
|
|
38
|
+
case 'recent':
|
|
39
|
+
if (parts.length === 2)
|
|
40
|
+
return { path: 'recent' };
|
|
41
|
+
return parts.length === 3 && second === 'feed' ? { path: 'recent', segment: 'feed' } : { path: 'unknown' };
|
|
42
|
+
case 'scan':
|
|
43
|
+
return parts.length === 2 ? { path: 'scan' } : { path: 'unknown' };
|
|
44
|
+
case 'explore': {
|
|
45
|
+
if (parts.length === 3 && second === 'create')
|
|
46
|
+
return { path: 'explore', create: true };
|
|
47
|
+
if (parts.length !== 2)
|
|
48
|
+
return { path: 'unknown' };
|
|
49
|
+
const q = params.get('q')?.trim();
|
|
50
|
+
return q ? { path: 'explore', q } : { path: 'explore' };
|
|
51
|
+
}
|
|
52
|
+
case 'wallet': {
|
|
53
|
+
if (parts.length === 2)
|
|
54
|
+
return { path: 'wallet' };
|
|
55
|
+
if (parts.length === 3 && (second === 'documents' || second === 'history' || second === 'send'))
|
|
56
|
+
return { path: 'wallet', segment: second };
|
|
57
|
+
if (parts.length === 4 && second === 'card' && third)
|
|
58
|
+
return { path: 'wallet', segment: 'card', id: third };
|
|
59
|
+
return { path: 'unknown' };
|
|
60
|
+
}
|
|
61
|
+
case 'service':
|
|
62
|
+
return parts.length === 3 && second?.startsWith('did:') ? { path: 'service', did: second } : { path: 'unknown' };
|
|
63
|
+
case 'settings':
|
|
64
|
+
if (parts.length === 2)
|
|
65
|
+
return { path: 'settings' };
|
|
66
|
+
return parts.length === 3 && (second === 'backup' || second === 'devices')
|
|
67
|
+
? { path: 'settings', section: second }
|
|
68
|
+
: { path: 'unknown' };
|
|
69
|
+
case 'connect': {
|
|
70
|
+
if (parts.length !== 2)
|
|
71
|
+
return { path: 'unknown' };
|
|
72
|
+
const oob = params.get('oob')?.trim();
|
|
73
|
+
return oob ? { path: 'connect', oob } : { path: 'connect' };
|
|
74
|
+
}
|
|
75
|
+
default:
|
|
76
|
+
return { path: 'unknown' };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** The web routes of UI-LINK-001; every other destination is shown as "on your phone". */
|
|
80
|
+
export function hasWebRoute(destination) {
|
|
81
|
+
switch (destination.path) {
|
|
82
|
+
case 'home':
|
|
83
|
+
case 'service':
|
|
84
|
+
case 'connect':
|
|
85
|
+
return true;
|
|
86
|
+
case 'explore':
|
|
87
|
+
return !destination.create;
|
|
88
|
+
case 'settings':
|
|
89
|
+
return destination.section !== 'backup';
|
|
90
|
+
default:
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** A stable key for the destination's label (`link.destination.<key>` in the UI's strings). */
|
|
95
|
+
export function destinationKey(destination) {
|
|
96
|
+
switch (destination.path) {
|
|
97
|
+
case 'recent':
|
|
98
|
+
return destination.segment === 'feed' ? 'recentFeed' : 'recent';
|
|
99
|
+
case 'wallet':
|
|
100
|
+
return destination.segment === 'documents'
|
|
101
|
+
? 'walletDocuments'
|
|
102
|
+
: destination.segment === 'history'
|
|
103
|
+
? 'walletHistory'
|
|
104
|
+
: destination.segment === 'card'
|
|
105
|
+
? 'walletCard'
|
|
106
|
+
: destination.segment === 'send'
|
|
107
|
+
? 'walletSend'
|
|
108
|
+
: 'wallet';
|
|
109
|
+
case 'explore':
|
|
110
|
+
return destination.create ? 'exploreCreate' : 'explore';
|
|
111
|
+
case 'settings':
|
|
112
|
+
return destination.section === 'backup'
|
|
113
|
+
? 'settingsBackup'
|
|
114
|
+
: destination.section === 'devices'
|
|
115
|
+
? 'settingsDevices'
|
|
116
|
+
: 'settings';
|
|
117
|
+
default:
|
|
118
|
+
return destination.path;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The design of a conversation, written once for the two renderers of the Ayni UI: the browser (CSS) and the Ayni
|
|
3
|
+
* app (React Native styles). The numbers are CSS pixels, which React Native reads as density-independent points.
|
|
4
|
+
*
|
|
5
|
+
* The browser's stylesheet is the reference: a test in this repository compares it with these values, so the two
|
|
6
|
+
* cannot drift apart. The app builds its styles from them. A change of the conversation's look starts here.
|
|
7
|
+
*/
|
|
8
|
+
export declare const CONVERSATION_DESIGN: {
|
|
9
|
+
readonly transcript: {
|
|
10
|
+
readonly paddingTop: 16;
|
|
11
|
+
readonly paddingX: 20;
|
|
12
|
+
readonly paddingBottom: 20;
|
|
13
|
+
readonly gap: 18;
|
|
14
|
+
};
|
|
15
|
+
readonly bubble: {
|
|
16
|
+
/** a bubble never grows wider than this, nor than its row */
|
|
17
|
+
readonly maxWidth: 640;
|
|
18
|
+
readonly paddingTop: 9;
|
|
19
|
+
readonly paddingX: 14;
|
|
20
|
+
readonly paddingBottom: 8;
|
|
21
|
+
readonly radius: 16;
|
|
22
|
+
/** the one tighter corner: bottom right on the person's messages, top left on the agent's */
|
|
23
|
+
readonly tailRadius: 4;
|
|
24
|
+
/** between the quote, the body and the meta line */
|
|
25
|
+
readonly gap: 4;
|
|
26
|
+
/** room for the reaction pill, which overlaps the bubble's bottom edge */
|
|
27
|
+
readonly reactedPaddingBottom: 16;
|
|
28
|
+
readonly reactedMarginBottom: 12;
|
|
29
|
+
};
|
|
30
|
+
/** a system line: centred, no bubble */
|
|
31
|
+
readonly system: {
|
|
32
|
+
readonly fontSize: 13;
|
|
33
|
+
};
|
|
34
|
+
/** the time and "interrupted", at the end of the bubble */
|
|
35
|
+
readonly meta: {
|
|
36
|
+
readonly fontSize: 11;
|
|
37
|
+
readonly gap: 6;
|
|
38
|
+
readonly userTextOpacity: 0.78;
|
|
39
|
+
};
|
|
40
|
+
/** the message a reply answers, at the top of the bubble */
|
|
41
|
+
readonly quote: {
|
|
42
|
+
readonly paddingY: 5;
|
|
43
|
+
readonly paddingX: 10;
|
|
44
|
+
readonly borderWidth: 3;
|
|
45
|
+
readonly radius: 6;
|
|
46
|
+
readonly fontSize: 13;
|
|
47
|
+
readonly gap: 1;
|
|
48
|
+
readonly marginBottom: 2;
|
|
49
|
+
readonly whoWeight: 600;
|
|
50
|
+
readonly textOpacity: 0.9;
|
|
51
|
+
/** the share of the text colour mixed into a transparent background */
|
|
52
|
+
readonly tint: 0.07;
|
|
53
|
+
/** on the person's bubble: the share of the contrast colour in the background and in the border */
|
|
54
|
+
readonly userTint: 0.16;
|
|
55
|
+
readonly userBorderOpacity: 0.7;
|
|
56
|
+
};
|
|
57
|
+
/** the reactions of a message: one pill on the inner bottom corner, the same on both sides (UI-CONV-011) */
|
|
58
|
+
readonly reactionPill: {
|
|
59
|
+
readonly height: 26;
|
|
60
|
+
readonly paddingX: 8;
|
|
61
|
+
readonly radius: 13;
|
|
62
|
+
readonly borderWidth: 1;
|
|
63
|
+
readonly fontSize: 14;
|
|
64
|
+
readonly gap: 3;
|
|
65
|
+
/** how far the pill hangs below the bubble, and how far it steps out sideways */
|
|
66
|
+
readonly overlap: 13;
|
|
67
|
+
readonly outset: 6;
|
|
68
|
+
/** the share of the accent mixed into the background of a pill that holds the person's own reaction */
|
|
69
|
+
readonly mineTint: 0.12;
|
|
70
|
+
};
|
|
71
|
+
/** the button that returns to the latest message */
|
|
72
|
+
readonly latestButton: {
|
|
73
|
+
readonly size: 36;
|
|
74
|
+
readonly right: 20;
|
|
75
|
+
readonly bottom: 12;
|
|
76
|
+
readonly awayBeyond: 320;
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
export type ConversationDesign = typeof CONVERSATION_DESIGN;
|
|
80
|
+
/** The colour roles both palettes define, in the browser as custom properties and in the app as theme colours. */
|
|
81
|
+
export type ColorRole = 'bg' | 'surface' | 'text' | 'muted' | 'border' | 'accent' | 'accentContrast' | 'warning';
|
|
82
|
+
/** Which colour role paints what in a conversation. */
|
|
83
|
+
export declare const CONVERSATION_COLORS: {
|
|
84
|
+
readonly userBubble: {
|
|
85
|
+
readonly background: "accent";
|
|
86
|
+
readonly text: "accentContrast";
|
|
87
|
+
};
|
|
88
|
+
readonly assistantBubble: {
|
|
89
|
+
readonly background: "surface";
|
|
90
|
+
readonly text: "text";
|
|
91
|
+
};
|
|
92
|
+
readonly system: {
|
|
93
|
+
readonly text: "muted";
|
|
94
|
+
};
|
|
95
|
+
readonly meta: {
|
|
96
|
+
readonly text: "muted";
|
|
97
|
+
};
|
|
98
|
+
readonly interrupted: {
|
|
99
|
+
readonly text: "warning";
|
|
100
|
+
};
|
|
101
|
+
readonly quote: {
|
|
102
|
+
readonly border: "accent";
|
|
103
|
+
readonly who: "accent";
|
|
104
|
+
};
|
|
105
|
+
readonly reactionPill: {
|
|
106
|
+
readonly background: "bg";
|
|
107
|
+
readonly border: "border";
|
|
108
|
+
readonly text: "text";
|
|
109
|
+
readonly mineBorder: "accent";
|
|
110
|
+
};
|
|
111
|
+
readonly latestButton: {
|
|
112
|
+
readonly background: "bg";
|
|
113
|
+
readonly border: "border";
|
|
114
|
+
readonly icon: "text";
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
/** The side a message sits on, and the corner its bubble tightens. */
|
|
118
|
+
export declare function bubbleSide(role: 'user' | 'assistant' | 'system'): 'mine' | 'theirs' | 'system';
|
|
119
|
+
/** The quick row of the reaction picker (UI-CONV-017): the first six, with their English names. */
|
|
120
|
+
export declare const QUICK_REACTIONS: readonly [{
|
|
121
|
+
readonly e: "❤️";
|
|
122
|
+
readonly name: "heart";
|
|
123
|
+
}, {
|
|
124
|
+
readonly e: "👍";
|
|
125
|
+
readonly name: "thumbs up";
|
|
126
|
+
}, {
|
|
127
|
+
readonly e: "👎";
|
|
128
|
+
readonly name: "thumbs down";
|
|
129
|
+
}, {
|
|
130
|
+
readonly e: "😂";
|
|
131
|
+
readonly name: "laughing";
|
|
132
|
+
}, {
|
|
133
|
+
readonly e: "😮";
|
|
134
|
+
readonly name: "surprised";
|
|
135
|
+
}, {
|
|
136
|
+
readonly e: "😢";
|
|
137
|
+
readonly name: "sad";
|
|
138
|
+
}];
|
|
139
|
+
/** Markdown markers stripped: for the clipboard and for the quote of a reply. */
|
|
140
|
+
export declare function plainText(markdown: string): string;
|
|
141
|
+
/** The longest quote a reply carries. */
|
|
142
|
+
export declare const REPLY_QUOTE_LENGTH = 200;
|
|
143
|
+
/** The quote a reply carries (PROF-RUN-009): the message as plain text, shortened. */
|
|
144
|
+
export declare function replyQuoteOf(message: {
|
|
145
|
+
id: string;
|
|
146
|
+
role: 'user' | 'assistant' | 'system';
|
|
147
|
+
content: string;
|
|
148
|
+
}): {
|
|
149
|
+
id: string;
|
|
150
|
+
text: string;
|
|
151
|
+
role: 'user' | 'assistant';
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* The reactions of a message as its one pill shows them (UI-CONV-011): the person's own first, then the others in
|
|
155
|
+
* the order they came. `mine` is the person's emoji, when they reacted: the pill is then a control that changes it.
|
|
156
|
+
*/
|
|
157
|
+
export declare function reactionPillOf(reactions: readonly {
|
|
158
|
+
emoji: string;
|
|
159
|
+
by: string;
|
|
160
|
+
}[] | undefined, me: string | undefined): {
|
|
161
|
+
emojis: {
|
|
162
|
+
emoji: string;
|
|
163
|
+
by: string;
|
|
164
|
+
own: boolean;
|
|
165
|
+
}[];
|
|
166
|
+
mine?: string;
|
|
167
|
+
} | null;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The design of a conversation, written once for the two renderers of the Ayni UI: the browser (CSS) and the Ayni
|
|
3
|
+
* app (React Native styles). The numbers are CSS pixels, which React Native reads as density-independent points.
|
|
4
|
+
*
|
|
5
|
+
* The browser's stylesheet is the reference: a test in this repository compares it with these values, so the two
|
|
6
|
+
* cannot drift apart. The app builds its styles from them. A change of the conversation's look starts here.
|
|
7
|
+
*/
|
|
8
|
+
export const CONVERSATION_DESIGN = {
|
|
9
|
+
transcript: { paddingTop: 16, paddingX: 20, paddingBottom: 20, gap: 18 },
|
|
10
|
+
bubble: {
|
|
11
|
+
/** a bubble never grows wider than this, nor than its row */
|
|
12
|
+
maxWidth: 640,
|
|
13
|
+
paddingTop: 9,
|
|
14
|
+
paddingX: 14,
|
|
15
|
+
paddingBottom: 8,
|
|
16
|
+
radius: 16,
|
|
17
|
+
/** the one tighter corner: bottom right on the person's messages, top left on the agent's */
|
|
18
|
+
tailRadius: 4,
|
|
19
|
+
/** between the quote, the body and the meta line */
|
|
20
|
+
gap: 4,
|
|
21
|
+
/** room for the reaction pill, which overlaps the bubble's bottom edge */
|
|
22
|
+
reactedPaddingBottom: 16,
|
|
23
|
+
reactedMarginBottom: 12,
|
|
24
|
+
},
|
|
25
|
+
/** a system line: centred, no bubble */
|
|
26
|
+
system: { fontSize: 13 },
|
|
27
|
+
/** the time and "interrupted", at the end of the bubble */
|
|
28
|
+
meta: { fontSize: 11, gap: 6, userTextOpacity: 0.78 },
|
|
29
|
+
/** the message a reply answers, at the top of the bubble */
|
|
30
|
+
quote: {
|
|
31
|
+
paddingY: 5,
|
|
32
|
+
paddingX: 10,
|
|
33
|
+
borderWidth: 3,
|
|
34
|
+
radius: 6,
|
|
35
|
+
fontSize: 13,
|
|
36
|
+
gap: 1,
|
|
37
|
+
marginBottom: 2,
|
|
38
|
+
whoWeight: 600,
|
|
39
|
+
textOpacity: 0.9,
|
|
40
|
+
/** the share of the text colour mixed into a transparent background */
|
|
41
|
+
tint: 0.07,
|
|
42
|
+
/** on the person's bubble: the share of the contrast colour in the background and in the border */
|
|
43
|
+
userTint: 0.16,
|
|
44
|
+
userBorderOpacity: 0.7,
|
|
45
|
+
},
|
|
46
|
+
/** the reactions of a message: one pill on the inner bottom corner, the same on both sides (UI-CONV-011) */
|
|
47
|
+
reactionPill: {
|
|
48
|
+
height: 26,
|
|
49
|
+
paddingX: 8,
|
|
50
|
+
radius: 13,
|
|
51
|
+
borderWidth: 1,
|
|
52
|
+
fontSize: 14,
|
|
53
|
+
gap: 3,
|
|
54
|
+
/** how far the pill hangs below the bubble, and how far it steps out sideways */
|
|
55
|
+
overlap: 13,
|
|
56
|
+
outset: 6,
|
|
57
|
+
/** the share of the accent mixed into the background of a pill that holds the person's own reaction */
|
|
58
|
+
mineTint: 0.12,
|
|
59
|
+
},
|
|
60
|
+
/** the button that returns to the latest message */
|
|
61
|
+
latestButton: { size: 36, right: 20, bottom: 12, awayBeyond: 320 },
|
|
62
|
+
};
|
|
63
|
+
/** Which colour role paints what in a conversation. */
|
|
64
|
+
export const CONVERSATION_COLORS = {
|
|
65
|
+
userBubble: { background: 'accent', text: 'accentContrast' },
|
|
66
|
+
assistantBubble: { background: 'surface', text: 'text' },
|
|
67
|
+
system: { text: 'muted' },
|
|
68
|
+
meta: { text: 'muted' },
|
|
69
|
+
interrupted: { text: 'warning' },
|
|
70
|
+
quote: { border: 'accent', who: 'accent' },
|
|
71
|
+
reactionPill: { background: 'bg', border: 'border', text: 'text', mineBorder: 'accent' },
|
|
72
|
+
latestButton: { background: 'bg', border: 'border', icon: 'text' },
|
|
73
|
+
};
|
|
74
|
+
/** The side a message sits on, and the corner its bubble tightens. */
|
|
75
|
+
export function bubbleSide(role) {
|
|
76
|
+
return role === 'user' ? 'mine' : role === 'assistant' ? 'theirs' : 'system';
|
|
77
|
+
}
|
|
78
|
+
/** The quick row of the reaction picker (UI-CONV-017): the first six, with their English names. */
|
|
79
|
+
export const QUICK_REACTIONS = [
|
|
80
|
+
{ e: '❤️', name: 'heart' },
|
|
81
|
+
{ e: '👍', name: 'thumbs up' },
|
|
82
|
+
{ e: '👎', name: 'thumbs down' },
|
|
83
|
+
{ e: '😂', name: 'laughing' },
|
|
84
|
+
{ e: '😮', name: 'surprised' },
|
|
85
|
+
{ e: '😢', name: 'sad' },
|
|
86
|
+
];
|
|
87
|
+
/** Markdown markers stripped: for the clipboard and for the quote of a reply. */
|
|
88
|
+
export function plainText(markdown) {
|
|
89
|
+
return markdown
|
|
90
|
+
.replace(/<\/?(u|b|i|em|strong|s)>/g, '')
|
|
91
|
+
.replace(/\*\*\*([^*]+)\*\*\*/g, '$1')
|
|
92
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
93
|
+
.replace(/\*([^*\n]+)\*/g, '$1')
|
|
94
|
+
.replace(/~~([^~]+)~~/g, '$1')
|
|
95
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
96
|
+
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1 ($2)')
|
|
97
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
98
|
+
.replace(/^>\s?/gm, '')
|
|
99
|
+
.replace(/^\s*([-*_])\s*\1\s*\1[\s\-*_]*$/gm, '')
|
|
100
|
+
.replace(/^[-*+]\s+/gm, '• ');
|
|
101
|
+
}
|
|
102
|
+
/** The longest quote a reply carries. */
|
|
103
|
+
export const REPLY_QUOTE_LENGTH = 200;
|
|
104
|
+
/** The quote a reply carries (PROF-RUN-009): the message as plain text, shortened. */
|
|
105
|
+
export function replyQuoteOf(message) {
|
|
106
|
+
return {
|
|
107
|
+
id: message.id,
|
|
108
|
+
text: plainText(message.content).slice(0, REPLY_QUOTE_LENGTH),
|
|
109
|
+
role: message.role === 'user' ? 'user' : 'assistant',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The reactions of a message as its one pill shows them (UI-CONV-011): the person's own first, then the others in
|
|
114
|
+
* the order they came. `mine` is the person's emoji, when they reacted: the pill is then a control that changes it.
|
|
115
|
+
*/
|
|
116
|
+
export function reactionPillOf(reactions, me) {
|
|
117
|
+
if (!reactions?.length)
|
|
118
|
+
return null;
|
|
119
|
+
const own = reactions.find((r) => r.by === me);
|
|
120
|
+
const others = reactions.filter((r) => r.by !== me);
|
|
121
|
+
return {
|
|
122
|
+
emojis: [...(own ? [{ ...own, own: true }] : []), ...others.map((r) => ({ ...r, own: false }))],
|
|
123
|
+
mine: own?.emoji,
|
|
124
|
+
};
|
|
125
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export * from './agents.js';
|
|
2
|
+
export * from './appLinks.js';
|
|
3
|
+
export * from './conversationDesign.js';
|
|
2
4
|
export * from './directory.js';
|
|
3
5
|
export * from './profileClient.js';
|
|
4
6
|
export * from './reducer.js';
|
|
@@ -9,7 +11,17 @@ export * from './trust.js';
|
|
|
9
11
|
export * from './types.js';
|
|
10
12
|
export declare const CLIENT: {
|
|
11
13
|
readonly name: "ayni-ui";
|
|
12
|
-
readonly version: "3.
|
|
14
|
+
readonly version: "3.1.0";
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* The client description sent at session creation (PROF-SESS-014): a 3.1 agent gets `platform: web` and the
|
|
18
|
+
* browser as `description`; a 3.0 agent gets the browser as the free-text platform it expects.
|
|
19
|
+
*/
|
|
20
|
+
export declare function clientDescription(profileVersion: string | undefined, browser: string): {
|
|
21
|
+
name: string;
|
|
22
|
+
version: string;
|
|
23
|
+
platform: string;
|
|
24
|
+
description?: string;
|
|
13
25
|
};
|
|
14
26
|
export declare const CLOSURE_REASON_KEYS: Record<string, string>;
|
|
15
27
|
/** Rail order (UI-SHELL-011): pinned in the person's order, then by most recent message, then not linked. */
|
|
@@ -50,10 +62,22 @@ export declare function controllerOf(agent: {
|
|
|
50
62
|
/** The flag emoji of an ISO 3166-1 alpha-2 code, or an empty string when the code is not one. */
|
|
51
63
|
export declare function flagOf(countryCode?: string): string;
|
|
52
64
|
/** Rail order for controllers (UI-SHELL-011): pinned in the person's order, then by activity, then unverified. */
|
|
65
|
+
/**
|
|
66
|
+
* The stable rail (UI-SHELL-011, D37): pinned controllers in the person's order, then the identified ones (an
|
|
67
|
+
* agent of theirs issued the person a credential), then the others, both in connection order, then the unverified
|
|
68
|
+
* tiles. Nothing here depends on activity: a message never moves a tile.
|
|
69
|
+
*/
|
|
53
70
|
export declare function orderControllers<T extends {
|
|
54
71
|
did: string;
|
|
55
72
|
kind: string;
|
|
56
73
|
pinned: boolean;
|
|
57
74
|
pinOrder: number;
|
|
58
|
-
|
|
75
|
+
identifiedAt?: string;
|
|
76
|
+
}>(controllers: T[], connectedAt: (c: T) => string): T[];
|
|
77
|
+
/** Whether a transcript records a credential issued to the person: a completed `issue` flow card (D37). */
|
|
78
|
+
export declare function hasIssuedCredential(messages: {
|
|
79
|
+
role: string;
|
|
80
|
+
activityType?: string;
|
|
81
|
+
content?: unknown;
|
|
82
|
+
}[]): boolean;
|
|
59
83
|
export * from './trustDetails.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export * from './agents.js';
|
|
2
|
+
export * from './appLinks.js';
|
|
3
|
+
export * from './conversationDesign.js';
|
|
2
4
|
export * from './directory.js';
|
|
3
5
|
export * from './profileClient.js';
|
|
4
6
|
export * from './reducer.js';
|
|
@@ -7,7 +9,16 @@ export * from './store.js';
|
|
|
7
9
|
export * from './sync.js';
|
|
8
10
|
export * from './trust.js';
|
|
9
11
|
export * from './types.js';
|
|
10
|
-
export const CLIENT = { name: 'ayni-ui', version: '3.
|
|
12
|
+
export const CLIENT = { name: 'ayni-ui', version: '3.1.0' };
|
|
13
|
+
/**
|
|
14
|
+
* The client description sent at session creation (PROF-SESS-014): a 3.1 agent gets `platform: web` and the
|
|
15
|
+
* browser as `description`; a 3.0 agent gets the browser as the free-text platform it expects.
|
|
16
|
+
*/
|
|
17
|
+
export function clientDescription(profileVersion, browser) {
|
|
18
|
+
const [major, minor] = (profileVersion ?? '3.0').split('.').map(Number);
|
|
19
|
+
const supportsDescription = major > 3 || (major === 3 && (minor ?? 0) >= 1);
|
|
20
|
+
return supportsDescription ? { ...CLIENT, platform: 'web', description: browser } : { ...CLIENT, platform: browser };
|
|
21
|
+
}
|
|
11
22
|
export const CLOSURE_REASON_KEYS = {
|
|
12
23
|
user: 'closure.user',
|
|
13
24
|
supersede: 'closure.supersede',
|
|
@@ -57,15 +68,27 @@ export function flagOf(countryCode) {
|
|
|
57
68
|
return String.fromCodePoint(...[...code].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65));
|
|
58
69
|
}
|
|
59
70
|
/** Rail order for controllers (UI-SHELL-011): pinned in the person's order, then by activity, then unverified. */
|
|
60
|
-
|
|
71
|
+
/**
|
|
72
|
+
* The stable rail (UI-SHELL-011, D37): pinned controllers in the person's order, then the identified ones (an
|
|
73
|
+
* agent of theirs issued the person a credential), then the others, both in connection order, then the unverified
|
|
74
|
+
* tiles. Nothing here depends on activity: a message never moves a tile.
|
|
75
|
+
*/
|
|
76
|
+
export function orderControllers(controllers, connectedAt) {
|
|
77
|
+
const byConnection = (a, b) => connectedAt(a).localeCompare(connectedAt(b)) || a.did.localeCompare(b.did);
|
|
61
78
|
const pinned = controllers.filter((c) => c.pinned).sort((a, b) => a.pinOrder - b.pinOrder);
|
|
62
79
|
const rest = controllers.filter((c) => !c.pinned);
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
80
|
+
const identified = rest.filter((c) => c.kind !== 'unverified' && c.identifiedAt).sort(byConnection);
|
|
81
|
+
const others = rest.filter((c) => c.kind !== 'unverified' && !c.identifiedAt).sort(byConnection);
|
|
82
|
+
const unverified = rest.filter((c) => c.kind === 'unverified').sort(byConnection);
|
|
83
|
+
return [...pinned, ...identified, ...others, ...unverified];
|
|
84
|
+
}
|
|
85
|
+
/** Whether a transcript records a credential issued to the person: a completed `issue` flow card (D37). */
|
|
86
|
+
export function hasIssuedCredential(messages) {
|
|
87
|
+
return messages.some((m) => {
|
|
88
|
+
if (m.role !== 'activity' || m.activityType !== 'ayni.flow')
|
|
89
|
+
return false;
|
|
90
|
+
const c = (m.content ?? {});
|
|
91
|
+
return c.kind === 'issue' && c.state === 'completed';
|
|
92
|
+
});
|
|
70
93
|
}
|
|
71
94
|
export * from './trustDetails.js';
|
package/dist/reducer.d.ts
CHANGED
|
@@ -4,9 +4,11 @@ export interface Reduction {
|
|
|
4
4
|
transcript: Transcript;
|
|
5
5
|
session: SessionRecord;
|
|
6
6
|
/** signals the shell reacts to (title, badge, sound) */
|
|
7
|
-
signals: ('message' | 'bound' | 'closed' | 'token' | 'approval')[];
|
|
7
|
+
signals: ('message' | 'bound' | 'closed' | 'token' | 'approval' | 'identified')[];
|
|
8
8
|
}
|
|
9
9
|
export declare function emptyTranscript(sessionId: string): Transcript;
|
|
10
|
+
/** The five closed states of a flow (flow profile §5.2). */
|
|
11
|
+
export declare function isClosedFlow(state: unknown): boolean;
|
|
10
12
|
export declare function reduce(transcript: Transcript, session: SessionRecord, event: AgUiEvent, fromRun: boolean, isOnScreen: boolean): Reduction;
|
|
11
13
|
/** History messages (MessagesSnapshot shape) to transcript messages (UI-CONV-012). */
|
|
12
14
|
export declare function toTranscriptMessage(m: Record<string, unknown>): TranscriptMessage;
|
package/dist/reducer.js
CHANGED
|
@@ -7,6 +7,10 @@ export function emptyTranscript(sessionId) {
|
|
|
7
7
|
return { sessionId, messages: [], oldestLoadedId: null, complete: false, run: null, routed: {}, reactions: {} };
|
|
8
8
|
}
|
|
9
9
|
const now = () => new Date().toISOString();
|
|
10
|
+
/** The five closed states of a flow (flow profile §5.2). */
|
|
11
|
+
export function isClosedFlow(state) {
|
|
12
|
+
return (state === 'completed' || state === 'declined' || state === 'cancelled' || state === 'failed' || state === 'expired');
|
|
13
|
+
}
|
|
10
14
|
function upsertMessage(messages, message) {
|
|
11
15
|
const index = messages.findIndex((m) => m.id === message.id);
|
|
12
16
|
if (index < 0)
|
|
@@ -116,17 +120,28 @@ export function reduce(transcript, session, event, fromRun, isOnScreen) {
|
|
|
116
120
|
const id = String(event.messageId);
|
|
117
121
|
// a late artifact replaces its generating placeholder at the initiation position (UI-CONV-006)
|
|
118
122
|
let messages = t.messages;
|
|
123
|
+
let routed = t.routed;
|
|
124
|
+
// a flow card sits at the flow's open time and is replaced in place at its close (UI-CONV-021, PROF-MAP-007);
|
|
125
|
+
// once closed it takes over from the "continue on your phone" card of the same flow (UI-CAP-005)
|
|
126
|
+
const flowAt = activityType === ACTIVITY.flow && typeof content.openedAt === 'string' ? content.openedAt : at;
|
|
127
|
+
if (activityType === ACTIVITY.flow && content.flowId && isClosedFlow(content.state)) {
|
|
128
|
+
routed = Object.fromEntries(Object.entries(routed).filter(([, r]) => r.flowId !== content.flowId));
|
|
129
|
+
// a credential issued in this conversation identifies its controller on the rail (D37)
|
|
130
|
+
if (content.kind === 'issue' && content.state === 'completed')
|
|
131
|
+
signals.push('identified');
|
|
132
|
+
}
|
|
119
133
|
if (activityType === ACTIVITY.attachment && content.entryId) {
|
|
120
134
|
messages = messages.filter((m) => !(m.role === 'activity' && m.activityType === ACTIVITY.generating && m.content.entryId === content.entryId));
|
|
121
135
|
}
|
|
122
136
|
t = {
|
|
123
137
|
...t,
|
|
138
|
+
routed,
|
|
124
139
|
messages: upsertMessage(messages, {
|
|
125
140
|
id,
|
|
126
141
|
role: 'activity',
|
|
127
142
|
activityType,
|
|
128
143
|
content,
|
|
129
|
-
at,
|
|
144
|
+
at: flowAt,
|
|
130
145
|
entryId: content.entryId,
|
|
131
146
|
}),
|
|
132
147
|
};
|
|
@@ -200,6 +215,7 @@ export function reduce(transcript, session, event, fromRun, isOnScreen) {
|
|
|
200
215
|
status: String(value.status),
|
|
201
216
|
message: value.message,
|
|
202
217
|
at,
|
|
218
|
+
flowId: typeof value.flowId === 'string' ? value.flowId : undefined,
|
|
203
219
|
},
|
|
204
220
|
},
|
|
205
221
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -40,6 +40,8 @@ export interface ControllerRecord {
|
|
|
40
40
|
countryCode?: string;
|
|
41
41
|
pinned: boolean;
|
|
42
42
|
pinOrder: number;
|
|
43
|
+
/** when one of its agents first issued the person a credential, known from a completed `issue` flow card (D37) */
|
|
44
|
+
identifiedAt?: string;
|
|
43
45
|
}
|
|
44
46
|
export interface AgentRecord {
|
|
45
47
|
did: string;
|
|
@@ -139,12 +141,14 @@ export interface Transcript {
|
|
|
139
141
|
complete: boolean;
|
|
140
142
|
/** the run in progress, if any (one at a time per session, UI-CONV-003) */
|
|
141
143
|
run: RunProgress | null;
|
|
144
|
+
/** trust-bound events being served on the phone (UI-CAP-001); `flowId` when served as a flow (PROF-MAP-007) */
|
|
142
145
|
routed: Record<string, {
|
|
143
146
|
kind: string;
|
|
144
147
|
challengeType?: string;
|
|
145
148
|
status: string;
|
|
146
149
|
message?: string;
|
|
147
150
|
at: string;
|
|
151
|
+
flowId?: string;
|
|
148
152
|
}>;
|
|
149
153
|
/** current reactions per message, one per person (UI-CONV-011) */
|
|
150
154
|
reactions: Record<string, {
|
|
@@ -180,6 +184,9 @@ export interface RuntimeConfig {
|
|
|
180
184
|
defaultLocale?: string;
|
|
181
185
|
soundsEnabled: boolean;
|
|
182
186
|
installPromptEnabled: boolean;
|
|
187
|
+
/** store pages of the Ayni app, shown by the web fallback of an app link (CC-WEB-002); absent until published */
|
|
188
|
+
appStoreUrl?: string;
|
|
189
|
+
playStoreUrl?: string;
|
|
183
190
|
}
|
|
184
191
|
export interface BrandConfig {
|
|
185
192
|
appName: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ayni.org/ayni-ui-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Framework-independent core of the Ayni UI (UI-SHELL-004): agents and controllers, trust resolution, sessions and binding, the conversation reducer, the local store and sync; shared by the browser UI and the Ayni app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|