@fiodos/web-core 0.1.11 → 0.1.14
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/cjs/adapters/webCredentialAutofillAdapter.d.ts +16 -0
- package/dist/cjs/adapters/webCredentialAutofillAdapter.js +47 -0
- package/dist/cjs/api/backendClient.js +14 -0
- package/dist/cjs/config/types.d.ts +21 -2
- package/dist/cjs/controller/AgentController.d.ts +35 -1
- package/dist/cjs/controller/AgentController.js +123 -3
- package/dist/cjs/dropin/createFiodosAgent.d.ts +32 -1
- package/dist/cjs/dropin/createFiodosAgent.js +39 -1
- package/dist/cjs/embed/dynamics.d.ts +61 -0
- package/dist/cjs/embed/dynamics.js +173 -0
- package/dist/cjs/embed/global.d.ts +45 -0
- package/dist/cjs/embed/global.js +48 -0
- package/dist/cjs/index.d.ts +3 -0
- package/dist/cjs/index.js +8 -1
- package/dist/cjs/orb/mountOrb.js +111 -18
- package/dist/cjs/orb/orbView.js +40 -7
- package/dist/cjs/orb/publishedConfig.d.ts +4 -2
- package/dist/cjs/orb/publishedConfig.js +56 -7
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/embed/fiodos-embed.js +66 -0
- package/dist/esm/adapters/webCredentialAutofillAdapter.d.ts +16 -0
- package/dist/esm/adapters/webCredentialAutofillAdapter.js +44 -0
- package/dist/esm/api/backendClient.js +14 -0
- package/dist/esm/config/types.d.ts +21 -2
- package/dist/esm/controller/AgentController.d.ts +35 -1
- package/dist/esm/controller/AgentController.js +124 -4
- package/dist/esm/dropin/createFiodosAgent.d.ts +32 -1
- package/dist/esm/dropin/createFiodosAgent.js +39 -1
- package/dist/esm/embed/dynamics.d.ts +61 -0
- package/dist/esm/embed/dynamics.js +168 -0
- package/dist/esm/embed/global.d.ts +45 -0
- package/dist/esm/embed/global.js +46 -0
- package/dist/esm/index.d.ts +3 -0
- package/dist/esm/index.js +3 -0
- package/dist/esm/orb/mountOrb.js +112 -19
- package/dist/esm/orb/orbView.js +41 -8
- package/dist/esm/orb/publishedConfig.d.ts +4 -2
- package/dist/esm/orb/publishedConfig.js +56 -7
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +5 -3
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// ── Value normalization ──────────────────────────────────────────────────────
|
|
2
|
+
const MAX_VALUE_CHARS = 120;
|
|
3
|
+
function clip(text) {
|
|
4
|
+
const t = text.trim();
|
|
5
|
+
return t.length > MAX_VALUE_CHARS ? `${t.slice(0, MAX_VALUE_CHARS - 1)}…` : t;
|
|
6
|
+
}
|
|
7
|
+
/** Lookup values arrive as [{ id, name, entityType }] — show the names. */
|
|
8
|
+
function lookupNames(value) {
|
|
9
|
+
const names = value
|
|
10
|
+
.map((v) => v && typeof v === 'object' && typeof v.name === 'string'
|
|
11
|
+
? (v.name)
|
|
12
|
+
: null)
|
|
13
|
+
.filter((n) => !!n);
|
|
14
|
+
return names.length ? names.join(', ') : null;
|
|
15
|
+
}
|
|
16
|
+
function normalizeAttributeValue(attribute) {
|
|
17
|
+
// Optionset / lookup / boolean attributes carry a human label — prefer it.
|
|
18
|
+
try {
|
|
19
|
+
const text = attribute.getText?.();
|
|
20
|
+
if (typeof text === 'string' && text.trim())
|
|
21
|
+
return clip(text);
|
|
22
|
+
if (Array.isArray(text) && text.length)
|
|
23
|
+
return clip(text.join(', '));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
/* some attribute types throw on getText — fall through to raw value */
|
|
27
|
+
}
|
|
28
|
+
let value;
|
|
29
|
+
try {
|
|
30
|
+
value = attribute.getValue();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if (value == null)
|
|
36
|
+
return null;
|
|
37
|
+
if (typeof value === 'string')
|
|
38
|
+
return value.trim() ? clip(value) : null;
|
|
39
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
40
|
+
return value;
|
|
41
|
+
if (value instanceof Date)
|
|
42
|
+
return value.toISOString();
|
|
43
|
+
if (Array.isArray(value)) {
|
|
44
|
+
const names = lookupNames(value);
|
|
45
|
+
return names ? clip(names) : null;
|
|
46
|
+
}
|
|
47
|
+
return null; // collections / unknown object shapes are skipped, never guessed
|
|
48
|
+
}
|
|
49
|
+
/** "{1D2C…}" → "1d2c…" (Web API URLs want bare lowercase GUIDs). */
|
|
50
|
+
export function normalizeDynamicsId(rawId) {
|
|
51
|
+
return rawId.replace(/[{}]/g, '').trim().toLowerCase();
|
|
52
|
+
}
|
|
53
|
+
// ── Snapshot builder ─────────────────────────────────────────────────────────
|
|
54
|
+
/**
|
|
55
|
+
* Builds the AgentScreenSnapshot for the current form state, or null when
|
|
56
|
+
* there is nothing referenceable yet (create form: no record id).
|
|
57
|
+
*/
|
|
58
|
+
export function dynamicsFormSnapshot(formContext, options = {}) {
|
|
59
|
+
const entity = formContext.data?.entity;
|
|
60
|
+
if (!entity)
|
|
61
|
+
return null;
|
|
62
|
+
const entityName = entity.getEntityName();
|
|
63
|
+
const recordId = normalizeDynamicsId(entity.getId() ?? '');
|
|
64
|
+
const allowlist = options.attributes?.length
|
|
65
|
+
? new Set(options.attributes.map((a) => a.trim().toLowerCase()).filter(Boolean))
|
|
66
|
+
: null;
|
|
67
|
+
const maxAttributes = options.maxAttributes ?? 12;
|
|
68
|
+
const fields = {};
|
|
69
|
+
let count = 0;
|
|
70
|
+
entity.attributes?.forEach((attribute) => {
|
|
71
|
+
let name;
|
|
72
|
+
try {
|
|
73
|
+
name = attribute.getName();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (!name)
|
|
79
|
+
return;
|
|
80
|
+
if (allowlist) {
|
|
81
|
+
if (!allowlist.has(name.toLowerCase()))
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
else if (count >= maxAttributes) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const value = normalizeAttributeValue(attribute);
|
|
88
|
+
if (value == null)
|
|
89
|
+
return;
|
|
90
|
+
fields[name] = value;
|
|
91
|
+
count += 1;
|
|
92
|
+
});
|
|
93
|
+
let title;
|
|
94
|
+
try {
|
|
95
|
+
title = entity.getPrimaryAttributeValue?.() ?? undefined;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
title = undefined;
|
|
99
|
+
}
|
|
100
|
+
// Create forms have no id yet: the agent can still SEE the fields, but there
|
|
101
|
+
// is no target for execute actions, so we publish text-only context.
|
|
102
|
+
const item = recordId
|
|
103
|
+
? { id: recordId, kind: entityName, title, metadata: fields }
|
|
104
|
+
: null;
|
|
105
|
+
const fieldSummary = Object.entries(fields)
|
|
106
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
107
|
+
.join('; ');
|
|
108
|
+
const textParts = [
|
|
109
|
+
`Dynamics 365 form — entity "${entityName}"`,
|
|
110
|
+
recordId ? `record "${title ?? recordId}" (id ${recordId})` : 'new record (not saved yet)',
|
|
111
|
+
];
|
|
112
|
+
if (fieldSummary)
|
|
113
|
+
textParts.push(`Fields: ${fieldSummary}`);
|
|
114
|
+
const context = {
|
|
115
|
+
...(item ? { visibleContent: { items: [item], focusedIndex: 0 } } : {}),
|
|
116
|
+
custom: {
|
|
117
|
+
platform: 'dynamics365',
|
|
118
|
+
entityName,
|
|
119
|
+
...(recordId ? { recordId } : {}),
|
|
120
|
+
...(typeof formContext.ui?.getFormType === 'function'
|
|
121
|
+
? { formType: formContext.ui.getFormType() }
|
|
122
|
+
: {}),
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
return { kind: `dynamics-form:${entityName}`, text: textParts.join('. '), context };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Publishes the form snapshot now, republishes after every save (when the
|
|
129
|
+
* form exposes OnPostSave), and clears the context on disconnect. Call it from
|
|
130
|
+
* the form's OnLoad handler.
|
|
131
|
+
*/
|
|
132
|
+
export function connectDynamicsFormContext(agent, formContext, options = {}) {
|
|
133
|
+
let disconnected = false;
|
|
134
|
+
const publish = () => {
|
|
135
|
+
if (disconnected)
|
|
136
|
+
return;
|
|
137
|
+
const snapshot = dynamicsFormSnapshot(formContext, options);
|
|
138
|
+
if (snapshot)
|
|
139
|
+
agent.setScreenContext(snapshot);
|
|
140
|
+
else
|
|
141
|
+
agent.clearScreenContext();
|
|
142
|
+
};
|
|
143
|
+
publish();
|
|
144
|
+
const entity = formContext.data?.entity;
|
|
145
|
+
if (entity?.addOnPostSave) {
|
|
146
|
+
try {
|
|
147
|
+
entity.addOnPostSave(publish);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
/* older clients — the host can still call refresh() manually */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
refresh: publish,
|
|
155
|
+
disconnect() {
|
|
156
|
+
if (disconnected)
|
|
157
|
+
return;
|
|
158
|
+
disconnected = true;
|
|
159
|
+
try {
|
|
160
|
+
entity?.removeOnPostSave?.(publish);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
/* best-effort unsubscribe */
|
|
164
|
+
}
|
|
165
|
+
agent.clearScreenContext();
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embed entrypoint — single-file IIFE bundle for CLOSED third-party hosts
|
|
3
|
+
* where npm/ESM is not available: Dynamics 365 web resources, WinCC Unified
|
|
4
|
+
* Custom Web Controls, Salesforce LWC iframes, plain <script> tags.
|
|
5
|
+
*
|
|
6
|
+
* Built by `npm run build:embed` into dist/embed/fiodos-embed.js and exposed
|
|
7
|
+
* as the `Fiodos` global:
|
|
8
|
+
*
|
|
9
|
+
* <script src="fiodos-embed.js"></script>
|
|
10
|
+
* <script>
|
|
11
|
+
* const agent = Fiodos.createFiodosAgent({
|
|
12
|
+
* manifest, // e.g. from `fiodos from-odata`
|
|
13
|
+
* registries: Fiodos.buildApiActionRegistries(manifest, {
|
|
14
|
+
* baseUrl: 'https://org.crm.dynamics.com/api/data/v9.2',
|
|
15
|
+
* authProviders: { dataverse: () => ({ Authorization: `Bearer ${token}` }) },
|
|
16
|
+
* }),
|
|
17
|
+
* });
|
|
18
|
+
* </script>
|
|
19
|
+
*
|
|
20
|
+
* The bundle is self-contained (core + web-core inlined) and side-effect-free
|
|
21
|
+
* beyond assigning the global — it never auto-mounts; the host decides when.
|
|
22
|
+
*/
|
|
23
|
+
import { createFiodosAgent } from '../dropin/createFiodosAgent.js';
|
|
24
|
+
import { mountOrb } from '../orb/mountOrb.js';
|
|
25
|
+
import { AgentController } from '../controller/AgentController.js';
|
|
26
|
+
import { createScreenContextStore } from '../context/screenContextStore.js';
|
|
27
|
+
import { connectDynamicsFormContext, dynamicsFormSnapshot, normalizeDynamicsId } from './dynamics.js';
|
|
28
|
+
import { buildApiActionRegistries, validateManifest } from '@fiodos/core';
|
|
29
|
+
declare const api: {
|
|
30
|
+
readonly version: "0.1.14";
|
|
31
|
+
readonly createFiodosAgent: typeof createFiodosAgent;
|
|
32
|
+
readonly mountOrb: typeof mountOrb;
|
|
33
|
+
readonly AgentController: typeof AgentController;
|
|
34
|
+
readonly createScreenContextStore: typeof createScreenContextStore;
|
|
35
|
+
readonly buildApiActionRegistries: typeof buildApiActionRegistries;
|
|
36
|
+
readonly validateManifest: typeof validateManifest;
|
|
37
|
+
readonly connectDynamicsFormContext: typeof connectDynamicsFormContext;
|
|
38
|
+
readonly dynamicsFormSnapshot: typeof dynamicsFormSnapshot;
|
|
39
|
+
readonly normalizeDynamicsId: typeof normalizeDynamicsId;
|
|
40
|
+
};
|
|
41
|
+
declare global {
|
|
42
|
+
var Fiodos: typeof api | undefined;
|
|
43
|
+
}
|
|
44
|
+
export type FiodosEmbedApi = typeof api;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embed entrypoint — single-file IIFE bundle for CLOSED third-party hosts
|
|
3
|
+
* where npm/ESM is not available: Dynamics 365 web resources, WinCC Unified
|
|
4
|
+
* Custom Web Controls, Salesforce LWC iframes, plain <script> tags.
|
|
5
|
+
*
|
|
6
|
+
* Built by `npm run build:embed` into dist/embed/fiodos-embed.js and exposed
|
|
7
|
+
* as the `Fiodos` global:
|
|
8
|
+
*
|
|
9
|
+
* <script src="fiodos-embed.js"></script>
|
|
10
|
+
* <script>
|
|
11
|
+
* const agent = Fiodos.createFiodosAgent({
|
|
12
|
+
* manifest, // e.g. from `fiodos from-odata`
|
|
13
|
+
* registries: Fiodos.buildApiActionRegistries(manifest, {
|
|
14
|
+
* baseUrl: 'https://org.crm.dynamics.com/api/data/v9.2',
|
|
15
|
+
* authProviders: { dataverse: () => ({ Authorization: `Bearer ${token}` }) },
|
|
16
|
+
* }),
|
|
17
|
+
* });
|
|
18
|
+
* </script>
|
|
19
|
+
*
|
|
20
|
+
* The bundle is self-contained (core + web-core inlined) and side-effect-free
|
|
21
|
+
* beyond assigning the global — it never auto-mounts; the host decides when.
|
|
22
|
+
*/
|
|
23
|
+
import { createFiodosAgent } from '../dropin/createFiodosAgent.js';
|
|
24
|
+
import { mountOrb } from '../orb/mountOrb.js';
|
|
25
|
+
import { AgentController } from '../controller/AgentController.js';
|
|
26
|
+
import { createScreenContextStore } from '../context/screenContextStore.js';
|
|
27
|
+
import { connectDynamicsFormContext, dynamicsFormSnapshot, normalizeDynamicsId, } from './dynamics.js';
|
|
28
|
+
import { SDK_VERSION } from '../version.js';
|
|
29
|
+
import { buildApiActionRegistries, validateManifest } from '@fiodos/core';
|
|
30
|
+
const api = {
|
|
31
|
+
version: SDK_VERSION,
|
|
32
|
+
createFiodosAgent,
|
|
33
|
+
mountOrb,
|
|
34
|
+
AgentController,
|
|
35
|
+
createScreenContextStore,
|
|
36
|
+
// Closed-software mode: turn ApiExecutionSpec manifests into live handlers,
|
|
37
|
+
// and validate hand-written / from-odata manifests before mounting.
|
|
38
|
+
buildApiActionRegistries,
|
|
39
|
+
validateManifest,
|
|
40
|
+
// Context bridges: tell the orb what the host is showing. Dynamics gets a
|
|
41
|
+
// dedicated mapper (formContext); any other host calls agent.setScreenContext.
|
|
42
|
+
connectDynamicsFormContext,
|
|
43
|
+
dynamicsFormSnapshot,
|
|
44
|
+
normalizeDynamicsId,
|
|
45
|
+
};
|
|
46
|
+
globalThis.Fiodos = api;
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export { createWebNavigationAdapter, BACK_ROUTE, } from './adapters/webNavigatio
|
|
|
26
26
|
export type { WebNavigationAdapter, WebNavigationAdapterOptions, } from './adapters/webNavigationAdapter.js';
|
|
27
27
|
export { createWebVoiceAdapter } from './adapters/webVoiceAdapter.js';
|
|
28
28
|
export type { WebVoiceAdapterOptions } from './adapters/webVoiceAdapter.js';
|
|
29
|
+
export { createWebCredentialAutofillAdapter } from './adapters/webCredentialAutofillAdapter.js';
|
|
29
30
|
export { createFiodosBackendClient, buildManifestPayload } from './api/backendClient.js';
|
|
30
31
|
export type { FiodosBackendClientOptions } from './api/backendClient.js';
|
|
31
32
|
export { createFiodosTelemetry } from './api/backendTelemetry.js';
|
|
@@ -36,6 +37,8 @@ export { createBridge } from './bridge/createBridge.js';
|
|
|
36
37
|
export type { Bridge } from './bridge/createBridge.js';
|
|
37
38
|
export { createScreenContextStore } from './context/screenContextStore.js';
|
|
38
39
|
export type { ScreenContextStore, AgentScreenSnapshot } from './context/screenContextStore.js';
|
|
40
|
+
export { connectDynamicsFormContext, dynamicsFormSnapshot, normalizeDynamicsId, } from './embed/dynamics.js';
|
|
41
|
+
export type { DynamicsFormContextLike, DynamicsSnapshotOptions, DynamicsContextBridge, ScreenContextSink, } from './embed/dynamics.js';
|
|
39
42
|
export { createSpeechSession } from './speech/speechSession.js';
|
|
40
43
|
export type { SpeechSession, SpeechSessionOptions, SpeechSessionState, SpeechSessionSnapshot, } from './speech/speechSession.js';
|
|
41
44
|
export { DEFAULT_AGENT_TIMINGS, DEFAULT_AGENT_CHAINING } from './config/types.js';
|
package/dist/esm/index.js
CHANGED
|
@@ -23,6 +23,7 @@ export { watchPublishedConfig } from './orb/publishedConfig.js';
|
|
|
23
23
|
export { createWebStorageAdapter } from './adapters/webStorageAdapter.js';
|
|
24
24
|
export { createWebNavigationAdapter, BACK_ROUTE, } from './adapters/webNavigationAdapter.js';
|
|
25
25
|
export { createWebVoiceAdapter } from './adapters/webVoiceAdapter.js';
|
|
26
|
+
export { createWebCredentialAutofillAdapter } from './adapters/webCredentialAutofillAdapter.js';
|
|
26
27
|
// ── Backend + telemetry (same wire contract as every platform) ────────────────
|
|
27
28
|
export { createFiodosBackendClient, buildManifestPayload } from './api/backendClient.js';
|
|
28
29
|
export { createFiodosTelemetry } from './api/backendTelemetry.js';
|
|
@@ -31,6 +32,8 @@ export { AgentApiError, isAgentApiError } from './api/errors.js';
|
|
|
31
32
|
export { createBridge } from './bridge/createBridge.js';
|
|
32
33
|
// ── Screen context ────────────────────────────────────────────────────────────
|
|
33
34
|
export { createScreenContextStore } from './context/screenContextStore.js';
|
|
35
|
+
// ── Closed-host context adapters (embed mode) ─────────────────────────────────
|
|
36
|
+
export { connectDynamicsFormContext, dynamicsFormSnapshot, normalizeDynamicsId, } from './embed/dynamics.js';
|
|
34
37
|
// ── Speech session ──────────────────────────────────────────────────────────────
|
|
35
38
|
export { createSpeechSession } from './speech/speechSession.js';
|
|
36
39
|
// ── Config / i18n ─────────────────────────────────────────────────────────────
|
package/dist/esm/orb/mountOrb.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* - CONFIRMATIONS: the same security model (manifest decides; strict phrase /
|
|
17
17
|
* deliberate tap for payment-grade; loose "yes" never resolves strict).
|
|
18
18
|
*/
|
|
19
|
-
import { DEFAULT_ORB_SCREEN_POSITION, createWebOrbHaptics, matchRouteIntent, resolveBubblePrompt, bubblePromptToAffirmative, resolveBubbleTheme, resolveBubblePadding, bubbleShapeToCss, resolveOrbAnchorPx, resolveOrbDeployment, snapOrbScreenPositionToSide, viewportPixelsToOrbScreenPosition, fiodosInputInkColor, fiodosInputMutedInkColor, shouldHideInternalOrb, } from '@fiodos/core';
|
|
19
|
+
import { DEFAULT_ORB_SCREEN_POSITION, createWebOrbHaptics, matchRouteIntent, resolveBubblePrompt, bubblePromptToAffirmative, resolveBubbleTheme, resolveBubblePadding, bubbleShapeToCss, applyBubbleShapeCss, resolveOrbAnchorPx, resolveOrbDeployment, snapOrbScreenPositionToSide, viewportPixelsToOrbScreenPosition, fiodosInputInkColor, fiodosInputMutedInkColor, shouldHideInternalOrb, } from '@fiodos/core';
|
|
20
20
|
import { buildKeyboardChip, createOrbVisual, DEFAULT_ORB_APPEARANCE, } from './orbView.js';
|
|
21
21
|
import { watchPublishedConfig } from './publishedConfig.js';
|
|
22
22
|
import { createBubbleDictation, scrollTextInputToEnd } from '../speech/bubbleDictation.js';
|
|
@@ -27,8 +27,23 @@ const CSS = `
|
|
|
27
27
|
.fyodos-orb-btn img{pointer-events:none;-webkit-user-drag:none;user-select:none;}
|
|
28
28
|
.fyodos-orb-btn:focus-visible{outline:2px solid #8ab6ff;outline-offset:4px;border-radius:50%;}
|
|
29
29
|
.fyodos-orb-anchor{position:relative;display:inline-flex;}
|
|
30
|
-
.fyodos-bubble{position:absolute;display:flex;flex-direction:column;width:min(320px,calc(100vw - 48px));max-height:340px;overflow:hidden;border-radius:16px;box-shadow:0 16px 40px rgba(0,0,0,0.28);border:1px solid rgba(160,180,220,0.22);animation:fyodos-fade-in .14s ease-out;}
|
|
31
|
-
.fyodos-bubble
|
|
30
|
+
.fyodos-bubble{position:absolute;display:flex;flex-direction:column;width:min(320px,calc(100vw - 48px));max-height:340px;overflow:hidden;border-radius:16px;box-shadow:0 16px 40px rgba(0,0,0,0.28);border:1px solid rgba(160,180,220,0.22);animation:fyodos-fade-in .14s ease-out;transition:width .25s cubic-bezier(0.22,1,0.36,1),max-height .25s cubic-bezier(0.22,1,0.36,1);cursor:zoom-in;}
|
|
31
|
+
.fyodos-bubble--expanded{width:min(560px,calc(100vw - 32px));max-height:min(620px,75vh);cursor:auto;}
|
|
32
|
+
.fyodos-bubble-user,.fyodos-bubble-reply{cursor:text;}
|
|
33
|
+
.fyodos-bubble-row{cursor:default;}
|
|
34
|
+
.fyodos-bubble--expanded .fyodos-bubble-scroll{padding:22px 24px 8px;gap:10px;}
|
|
35
|
+
.fyodos-bubble--expanded .fyodos-bubble-exchanges{gap:14px;}
|
|
36
|
+
.fyodos-bubble--expanded .fyodos-bubble-exchange{gap:9px;}
|
|
37
|
+
.fyodos-bubble--expanded .fyodos-bubble-exchange:first-child .fyodos-bubble-user{padding-right:0;}
|
|
38
|
+
.fyodos-bubble--expanded .fyodos-bubble-user{font-size:14.5px;line-height:21px;}
|
|
39
|
+
.fyodos-bubble--expanded .fyodos-bubble-reply{font-size:17px;line-height:25px;}
|
|
40
|
+
.fyodos-bubble--expanded .fyodos-bubble-row{padding:12px 16px;gap:10px;}
|
|
41
|
+
.fyodos-bubble--expanded .fyodos-bubble-busy{padding:8px 24px;}
|
|
42
|
+
.fyodos-bubble--expanded .fyodos-bubble-row input{padding:12px 14px;font-size:16px;}
|
|
43
|
+
.fyodos-bubble--expanded .fyodos-bubble-mic,.fyodos-bubble--expanded .fyodos-bubble-row button{width:38px;height:38px;min-width:38px;font-size:19px;}
|
|
44
|
+
.fyodos-bubble-close,.fyodos-bubble-unexpand{position:absolute;top:4px;z-index:1;display:flex;align-items:center;justify-content:center;border:none;background:transparent;font-size:18px;line-height:18px;width:28px;height:28px;cursor:pointer;padding:0;opacity:.75;}
|
|
45
|
+
.fyodos-bubble-close{right:6px;}
|
|
46
|
+
.fyodos-bubble-unexpand{right:38px;display:none;}
|
|
32
47
|
.fyodos-bubble-user-row{display:flex;flex-direction:row;align-items:flex-start;gap:6px;}
|
|
33
48
|
.fyodos-bubble-scroll{overflow-y:auto;padding:12px 14px 4px;display:flex;flex-direction:column;gap:6px;min-height:18px;scrollbar-width:none;-ms-overflow-style:none;}
|
|
34
49
|
.fyodos-bubble-scroll::-webkit-scrollbar{display:none;width:0;height:0;}
|
|
@@ -370,6 +385,11 @@ export function mountOrb(controller, options = {}) {
|
|
|
370
385
|
btn.addEventListener('lostpointercapture', onPointerUp);
|
|
371
386
|
// ── Text bubble ─────────────────────────────────────────────────────────────
|
|
372
387
|
let bubbleOpen = false;
|
|
388
|
+
// Reduced (default) size stays exactly as before. Tapping any EMPTY area of
|
|
389
|
+
// the panel (not a message, not the input row, not a button — so text stays
|
|
390
|
+
// selectable/copyable) grows it to the expanded layout; shrinking back is an
|
|
391
|
+
// explicit tap on `unexpandBtn` next to the close button.
|
|
392
|
+
let bubbleExpanded = false;
|
|
373
393
|
// When the mic is denied/unavailable, tapping the orb still puts it in the
|
|
374
394
|
// SAME "active/listening" visual state and reveals the keyboard chip — the
|
|
375
395
|
// user is never blocked, text is always an open avenue (Change 1).
|
|
@@ -406,6 +426,19 @@ export function mountOrb(controller, options = {}) {
|
|
|
406
426
|
closeBtn.textContent = '×';
|
|
407
427
|
closeBtn.setAttribute('aria-label', 'Close');
|
|
408
428
|
closeBtn.addEventListener('click', () => setBubble(false));
|
|
429
|
+
// Collapse button — only shown while expanded, right next to the close
|
|
430
|
+
// button (same minimalist style). Tapping an empty area of the panel is
|
|
431
|
+
// what EXPANDS it; shrinking back is always an explicit, discoverable tap.
|
|
432
|
+
const unexpandBtn = document.createElement('button');
|
|
433
|
+
unexpandBtn.type = 'button';
|
|
434
|
+
unexpandBtn.className = 'fyodos-bubble-unexpand';
|
|
435
|
+
unexpandBtn.innerHTML =
|
|
436
|
+
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7"></path></svg>';
|
|
437
|
+
unexpandBtn.setAttribute('aria-label', 'Collapse');
|
|
438
|
+
unexpandBtn.addEventListener('click', (e) => {
|
|
439
|
+
e.stopPropagation();
|
|
440
|
+
setBubbleExpanded(false);
|
|
441
|
+
});
|
|
409
442
|
// Loading row shown at the top while an older page of the session history
|
|
410
443
|
// streams in (scroll-to-top pagination).
|
|
411
444
|
const loaderRow = document.createElement('div');
|
|
@@ -559,8 +592,71 @@ export function mountOrb(controller, options = {}) {
|
|
|
559
592
|
micBtn.setAttribute('aria-label', active ? ui.micStopLabel : ui.micLabel);
|
|
560
593
|
micBtn.title = active ? ui.micStopLabel : ui.micLabel;
|
|
561
594
|
}
|
|
562
|
-
bubble.append(closeBtn, exchangeScroll, busyRow, inputRow);
|
|
595
|
+
bubble.append(closeBtn, unexpandBtn, exchangeScroll, busyRow, inputRow);
|
|
563
596
|
anchor.appendChild(bubble);
|
|
597
|
+
function setBubbleExpanded(expanded) {
|
|
598
|
+
if (bubbleExpanded === expanded)
|
|
599
|
+
return;
|
|
600
|
+
bubbleExpanded = expanded;
|
|
601
|
+
bubble.classList.toggle('fyodos-bubble--expanded', expanded);
|
|
602
|
+
unexpandBtn.style.display = expanded ? 'flex' : 'none';
|
|
603
|
+
positionBubble();
|
|
604
|
+
}
|
|
605
|
+
// Tap-to-expand: a plain click on the panel grows it. Text selection is
|
|
606
|
+
// never broken: a drag-selection is a move (threshold guard), a double/
|
|
607
|
+
// triple-click word/paragraph selection cancels the pending expand (detail
|
|
608
|
+
// guard + delay), and any active selection blocks it. Controls (input row,
|
|
609
|
+
// buttons) never expand.
|
|
610
|
+
let bubbleTapX = 0;
|
|
611
|
+
let bubbleTapY = 0;
|
|
612
|
+
let pendingExpandTimer = null;
|
|
613
|
+
const TAP_MOVE_THRESHOLD = 8;
|
|
614
|
+
const hasActiveSelection = () => {
|
|
615
|
+
const selection = typeof window !== 'undefined' ? window.getSelection?.() : null;
|
|
616
|
+
return !!selection && !selection.isCollapsed && selection.toString().length > 0;
|
|
617
|
+
};
|
|
618
|
+
const cancelPendingExpand = () => {
|
|
619
|
+
if (pendingExpandTimer != null) {
|
|
620
|
+
window.clearTimeout(pendingExpandTimer);
|
|
621
|
+
pendingExpandTimer = null;
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
bubble.addEventListener('pointerdown', (e) => {
|
|
625
|
+
bubbleTapX = e.clientX;
|
|
626
|
+
bubbleTapY = e.clientY;
|
|
627
|
+
});
|
|
628
|
+
bubble.addEventListener('click', (e) => {
|
|
629
|
+
if (bubbleExpanded)
|
|
630
|
+
return;
|
|
631
|
+
const target = e.target;
|
|
632
|
+
if (target?.closest('.fyodos-bubble-row, .fyodos-bubble-close, .fyodos-bubble-unexpand, button, input, a')) {
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const moved = Math.hypot(e.clientX - bubbleTapX, e.clientY - bubbleTapY);
|
|
636
|
+
if (moved > TAP_MOVE_THRESHOLD)
|
|
637
|
+
return;
|
|
638
|
+
// Double/triple click is a word/paragraph selection gesture, never expand
|
|
639
|
+
// (it also cancels the pending expand from its own first click).
|
|
640
|
+
if (e.detail > 1) {
|
|
641
|
+
cancelPendingExpand();
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
cancelPendingExpand();
|
|
645
|
+
const overText = !!target?.closest('.fyodos-bubble-user, .fyodos-bubble-reply');
|
|
646
|
+
if (!overText && !hasActiveSelection()) {
|
|
647
|
+
// Genuinely empty area, nothing selected — expand immediately.
|
|
648
|
+
setBubbleExpanded(true);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
// Over message text, or a selection may still be collapsing: expand after
|
|
652
|
+
// a short delay, re-checking the selection right before — a double-click's
|
|
653
|
+
// second click or a real selection cancels it.
|
|
654
|
+
pendingExpandTimer = window.setTimeout(() => {
|
|
655
|
+
pendingExpandTimer = null;
|
|
656
|
+
if (!hasActiveSelection())
|
|
657
|
+
setBubbleExpanded(true);
|
|
658
|
+
}, 280);
|
|
659
|
+
});
|
|
564
660
|
// Initial placement (after the bubble exists, so applyDeployment can lay it out).
|
|
565
661
|
reposition();
|
|
566
662
|
function applyBubbleTheme() {
|
|
@@ -571,6 +667,7 @@ export function mountOrb(controller, options = {}) {
|
|
|
571
667
|
if (modalTheme?.borderRadius != null)
|
|
572
668
|
bubble.style.borderRadius = `${modalTheme.borderRadius}px`;
|
|
573
669
|
closeBtn.style.color = t.bodyColor;
|
|
670
|
+
unexpandBtn.style.color = t.bodyColor;
|
|
574
671
|
exchangeList.querySelectorAll('.fyodos-bubble-user').forEach((node) => {
|
|
575
672
|
node.style.color = t.bodyColor;
|
|
576
673
|
});
|
|
@@ -605,6 +702,9 @@ export function mountOrb(controller, options = {}) {
|
|
|
605
702
|
controller.resetBubbleWindow();
|
|
606
703
|
// Closing the panel returns the orb to idle (also clears mic fallback).
|
|
607
704
|
micFallbackActive = false;
|
|
705
|
+
// Always re-open at the reduced size.
|
|
706
|
+
cancelPendingExpand();
|
|
707
|
+
setBubbleExpanded(false);
|
|
608
708
|
}
|
|
609
709
|
syncChip(controller.getState());
|
|
610
710
|
}
|
|
@@ -707,29 +807,17 @@ export function mountOrb(controller, options = {}) {
|
|
|
707
807
|
track.appendChild(second);
|
|
708
808
|
thoughtBubbleEl.appendChild(track);
|
|
709
809
|
}
|
|
710
|
-
function applyThoughtBubbleTheme() {
|
|
810
|
+
function applyThoughtBubbleTheme(widthPx) {
|
|
711
811
|
const t = bubbleTheme;
|
|
712
812
|
const { fontPx, padH, approxHeight } = resolveBubblePadding(t);
|
|
713
|
-
const shapeCss = bubbleShapeToCss(t, approxHeight);
|
|
813
|
+
const shapeCss = bubbleShapeToCss(t, approxHeight, 1, widthPx);
|
|
714
814
|
thoughtBubbleEl.style.fontSize = `${fontPx}px`;
|
|
715
815
|
thoughtBubbleEl.style.height = `${approxHeight}px`;
|
|
716
816
|
thoughtBubbleEl.style.padding = `0 ${padH}px`;
|
|
717
817
|
thoughtBubbleEl.style.color = t.textColor;
|
|
718
818
|
thoughtBubbleEl.style.background = t.backgroundColor;
|
|
719
819
|
thoughtBubbleEl.style.border = `${t.borderWidth}px solid ${t.borderColor}`;
|
|
720
|
-
|
|
721
|
-
if (!value) {
|
|
722
|
-
thoughtBubbleEl.style.removeProperty(prop);
|
|
723
|
-
return;
|
|
724
|
-
}
|
|
725
|
-
thoughtBubbleEl.style.setProperty(prop, value, 'important');
|
|
726
|
-
};
|
|
727
|
-
setImportant('border-radius', shapeCss.borderRadius);
|
|
728
|
-
setImportant('border-top-left-radius', shapeCss.borderTopLeftRadius);
|
|
729
|
-
setImportant('border-top-right-radius', shapeCss.borderTopRightRadius);
|
|
730
|
-
setImportant('border-bottom-left-radius', shapeCss.borderBottomLeftRadius);
|
|
731
|
-
setImportant('border-bottom-right-radius', shapeCss.borderBottomRightRadius);
|
|
732
|
-
setImportant('clip-path', shapeCss.clipPath);
|
|
820
|
+
applyBubbleShapeCss(thoughtBubbleEl, shapeCss);
|
|
733
821
|
}
|
|
734
822
|
function positionThoughtBubble() {
|
|
735
823
|
const dep = resolveOrbDeployment(effectivePosition());
|
|
@@ -764,6 +852,11 @@ export function mountOrb(controller, options = {}) {
|
|
|
764
852
|
thoughtBubbleEl.style.display = 'flex';
|
|
765
853
|
applyThoughtBubbleTheme();
|
|
766
854
|
layoutThoughtContent(prompt);
|
|
855
|
+
if (bubbleTheme.shape === 'cloud') {
|
|
856
|
+
const widthPx = thoughtBubbleEl.offsetWidth;
|
|
857
|
+
if (widthPx > 0)
|
|
858
|
+
applyThoughtBubbleTheme(widthPx);
|
|
859
|
+
}
|
|
767
860
|
positionThoughtBubble();
|
|
768
861
|
}
|
|
769
862
|
// ── Overlays (confirmation + consent) ────────────────────────────────────────
|
package/dist/esm/orb/orbView.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* pixel-for-pixel — no "basic circle" divergence. Vue / Svelte / Angular mount
|
|
9
9
|
* THIS orb through createFiodosAgent → mountOrb.
|
|
10
10
|
*/
|
|
11
|
-
import { chipBorderRadius, chipIconBaseSizePx, chipIconCenterOffset, chipIconSpec, computeOutwardBorderLayout, normalizeChipIconScale, normalizeKeyboardChipIcon, orbFillDepthBoxShadow, orbOuterGlowBoxShadow, resolveChipIconColor,
|
|
11
|
+
import { chipBorderRadius, chipIconBaseSizePx, chipIconCenterOffset, chipIconSpec, computeOutwardBorderLayout, normalizeChipIconScale, normalizeKeyboardChipIcon, orbFillDepthBoxShadow, orbOuterGlowBoxShadow, resolveChipIconColor, fiodosOrbMarkIdleHalfHeight, resolveFiodosOrbMarkAnimatedBars, resolveInnerBorderColor, resolveOrbBackgroundColor, } from '@fiodos/core';
|
|
12
12
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
13
13
|
export const DEFAULT_ORB_APPEARANCE = {
|
|
14
14
|
accentColor: '#4f8cff',
|
|
@@ -73,9 +73,14 @@ function buildDoubleBorder(opts) {
|
|
|
73
73
|
root.appendChild(mid);
|
|
74
74
|
return { root, fill };
|
|
75
75
|
}
|
|
76
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Neutral Fiodos brand mark (idle, no logo). Breathes very gently on web — a
|
|
78
|
+
* direct port of the landing hero orbs' waveform easing (see
|
|
79
|
+
* `fiodosOrbMarkIdleHalfHeight` in @fiodos/core) — deliberately subtler than
|
|
80
|
+
* and distinct from the volume-reactive waveform shown while listening.
|
|
81
|
+
*/
|
|
77
82
|
function buildOrbMark(orbSize) {
|
|
78
|
-
const { bars, color, radius } =
|
|
83
|
+
const { bars, color, radius } = resolveFiodosOrbMarkAnimatedBars(orbSize);
|
|
79
84
|
const svg = svgEl('svg', {
|
|
80
85
|
width: orbSize,
|
|
81
86
|
height: orbSize,
|
|
@@ -83,10 +88,31 @@ function buildOrbMark(orbSize) {
|
|
|
83
88
|
'aria-hidden': 'true',
|
|
84
89
|
});
|
|
85
90
|
svg.style.display = 'block';
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
91
|
+
const rects = bars.map((b) => {
|
|
92
|
+
const rect = svgEl('rect', { x: b.x, y: b.y, width: b.width, height: b.height, rx: radius, ry: radius, fill: color });
|
|
93
|
+
svg.appendChild(rect);
|
|
94
|
+
return rect;
|
|
95
|
+
});
|
|
96
|
+
let raf = 0;
|
|
97
|
+
return {
|
|
98
|
+
el: svg,
|
|
99
|
+
start() {
|
|
100
|
+
const t0 = performance.now();
|
|
101
|
+
const loop = (now) => {
|
|
102
|
+
raf = requestAnimationFrame(loop);
|
|
103
|
+
const t = (now - t0) / 1000;
|
|
104
|
+
bars.forEach((b, i) => {
|
|
105
|
+
const half = fiodosOrbMarkIdleHalfHeight(t, i, b.baseHalfHeight, b.maxHalfHeight);
|
|
106
|
+
rects[i].setAttribute('y', (b.centerY - half).toFixed(2));
|
|
107
|
+
rects[i].setAttribute('height', (2 * half).toFixed(2));
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
raf = requestAnimationFrame(loop);
|
|
111
|
+
},
|
|
112
|
+
stop() {
|
|
113
|
+
cancelAnimationFrame(raf);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
90
116
|
}
|
|
91
117
|
// ── Waveform (port of VoiceWaveform: 9 bars, bell profile, rAF) ───────────────
|
|
92
118
|
const WAVE_BARS = 9;
|
|
@@ -377,6 +403,7 @@ export function createOrbVisual(initial) {
|
|
|
377
403
|
let volume = 0;
|
|
378
404
|
let fillEl = null;
|
|
379
405
|
let waveform = null;
|
|
406
|
+
let markAnim = null;
|
|
380
407
|
function rebuild() {
|
|
381
408
|
const size = appearance.size ?? DEFAULT_ORB_APPEARANCE.size;
|
|
382
409
|
const fill = resolveOrbBackgroundColor({
|
|
@@ -388,6 +415,8 @@ export function createOrbVisual(initial) {
|
|
|
388
415
|
const interiorColor = resolveInnerBorderColor(appearance.innerBorderColor);
|
|
389
416
|
waveform?.stop();
|
|
390
417
|
waveform = null;
|
|
418
|
+
markAnim?.stop();
|
|
419
|
+
markAnim = null;
|
|
391
420
|
wrapper.replaceChildren();
|
|
392
421
|
const { root, fill: fillNode } = buildDoubleBorder({
|
|
393
422
|
fillDiameter: size,
|
|
@@ -409,6 +438,8 @@ export function createOrbVisual(initial) {
|
|
|
409
438
|
const waveColor = appearance.waveformColor ?? '#ffffff';
|
|
410
439
|
waveform?.stop();
|
|
411
440
|
waveform = null;
|
|
441
|
+
markAnim?.stop();
|
|
442
|
+
markAnim = null;
|
|
412
443
|
fillEl.replaceChildren();
|
|
413
444
|
// The "speaking" feedback now lives INSIDE the fill (a pulsing logo, or the
|
|
414
445
|
// rings + dot when there's no photo) to mirror the dashboard editor, so the
|
|
@@ -453,7 +484,9 @@ export function createOrbVisual(initial) {
|
|
|
453
484
|
return;
|
|
454
485
|
}
|
|
455
486
|
if (state === 'idle') {
|
|
456
|
-
|
|
487
|
+
markAnim = buildOrbMark(size);
|
|
488
|
+
fillEl.appendChild(markAnim.el);
|
|
489
|
+
markAnim.start();
|
|
457
490
|
}
|
|
458
491
|
}
|
|
459
492
|
rebuild();
|
|
@@ -60,7 +60,9 @@ export interface WatchPublishedConfigOptions {
|
|
|
60
60
|
}
|
|
61
61
|
/**
|
|
62
62
|
* Starts watching the published config. Returns an unsubscribe function.
|
|
63
|
-
* Best-effort: a failed fetch
|
|
64
|
-
*
|
|
63
|
+
* Best-effort: a failed fetch is SILENTLY skipped — the orb keeps its
|
|
64
|
+
* last-known-good (or cached) look instead of reverting to the default — and
|
|
65
|
+
* never throws into the host app. `onChange(null)` is reported ONLY when the
|
|
66
|
+
* backend confirms nothing is published.
|
|
65
67
|
*/
|
|
66
68
|
export declare function watchPublishedConfig(controller: AgentController, options: WatchPublishedConfigOptions): () => void;
|