@eshal-bot/chat-widget 0.2.28 → 0.2.30
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/{chat-widget-MarkdownRenderer-DoWoY9R7.esm.js → chat-widget-MarkdownRenderer-7uG4QL4-.esm.js} +6 -6
- package/dist/chat-widget-index-BOTASvJq.esm.js +20 -0
- package/dist/chat-widget-markdown-chunk.js +631 -330
- package/dist/chat-widget-markdown-chunk.min.js +9 -9
- package/dist/chat-widget.esm.js +1 -1
- package/dist/chat-widget.js +1139 -245
- package/dist/chat-widget.min.js +6 -6
- package/dist/chat-widget.umd.js +8 -8
- package/package.json +3 -2
- package/dist/chat-widget-index-BPiJscyi.esm.js +0 -20
|
@@ -88504,54 +88504,219 @@
|
|
|
88504
88504
|
}
|
|
88505
88505
|
|
|
88506
88506
|
/**
|
|
88507
|
-
*
|
|
88508
|
-
*
|
|
88509
|
-
*
|
|
88507
|
+
* EV-2850 (WAPT) - the agent's `eshal-ui`/`eshal-chart` fence output can be
|
|
88508
|
+
* influenced by the end user's own message or by prompt-injection planted in
|
|
88509
|
+
* a knowledge-base document. A2uiBlock renders `href`/`src` values straight
|
|
88510
|
+
* from that JSON with no filtering - a `javascript:` action URL renders as a
|
|
88511
|
+
* legitimate-looking button that executes attacker JS in the visitor's page
|
|
88512
|
+
* when clicked (an authenticated victim's session cookie, DOM, etc. are all
|
|
88513
|
+
* in scope; no account needed to trigger it).
|
|
88510
88514
|
*
|
|
88511
|
-
*
|
|
88512
|
-
*
|
|
88513
|
-
*
|
|
88514
|
-
*
|
|
88515
|
+
* Every `href`/`src` sink in A2uiBlock must go through this first. Only
|
|
88516
|
+
* absolute http(s) URLs pass; anything else (javascript:, data:, vbscript:,
|
|
88517
|
+
* a bare relative path, non-string) is dropped rather than rendered.
|
|
88518
|
+
*
|
|
88519
|
+
* EV-3956 / EV-4798 MERN-19: this is the ONE implementation. The agent-sdk,
|
|
88520
|
+
* widget and frontend renderers all import this file (the published SDK and
|
|
88521
|
+
* widget bundle it at build time, so their consumers never resolve
|
|
88522
|
+
* @eshal/shared). A fix made here reaches all three surfaces. Do not re-inline.
|
|
88523
|
+
*
|
|
88524
|
+
* Plain JavaScript on purpose: the widget's Rollup/Babel build compiles no
|
|
88525
|
+
* TypeScript from another package. Types live in safeUrl.d.ts.
|
|
88526
|
+
*/
|
|
88527
|
+
function safeUrl(u) {
|
|
88528
|
+
return typeof u === 'string' && /^https?:\/\//i.test(u) ? u : undefined;
|
|
88529
|
+
}
|
|
88530
|
+
|
|
88531
|
+
/**
|
|
88532
|
+
* Framework-free A2UI core shared by the three A2uiBlock renderers - the
|
|
88533
|
+
* agent-sdk, the widget and the frontend Testing Lab (EV-4798 MERN-19).
|
|
88534
|
+
*
|
|
88535
|
+
* It owns what the renderers must agree on and that is not presentation:
|
|
88536
|
+
* - the component registry: which `type` values route to a chart and which
|
|
88537
|
+
* to a component, and how a type string is normalised;
|
|
88538
|
+
* - sanitizeChartTitle: no DB/SQL annotation reaches a visible title;
|
|
88539
|
+
* - toolConfirmationFor: the structured answer of an approval card;
|
|
88540
|
+
* - linkSegments: which spans of card text become links (every href passes
|
|
88541
|
+
* safeUrl, so a `javascript:` link stays plain text);
|
|
88542
|
+
* - matchAnsweredChoice / findNextUserAnswer: the answered state of a
|
|
88543
|
+
* one-shot card, read from conversation history.
|
|
88544
|
+
*
|
|
88545
|
+
* Each renderer keeps its own components and styling (inline SVG in the SDK,
|
|
88546
|
+
* lucide + inline styles in the widget, Tailwind + shadcn in the frontend).
|
|
88515
88547
|
*
|
|
88516
|
-
*
|
|
88517
|
-
*
|
|
88518
|
-
*
|
|
88548
|
+
* Plain JavaScript (types in a2ui.d.ts) because the widget's Rollup/Babel
|
|
88549
|
+
* build compiles no TypeScript from another package; conservative syntax
|
|
88550
|
+
* (no `??`, no `?.`) because that build does not run this file through its
|
|
88551
|
+
* Babel preset either.
|
|
88519
88552
|
*/
|
|
88553
|
+
|
|
88554
|
+
// ── Component registry ─────────────────────────────────────────────────
|
|
88555
|
+
|
|
88556
|
+
/** Chart-family types the dispatchers route into their chart renderer. */
|
|
88557
|
+
const A2UI_CHART_TYPES = Object.freeze(['bar', 'line', 'area', 'pie', 'donut', 'scatter', 'bubble', 'radar', 'radial-bar', 'radial',
|
|
88558
|
+
// alias for radial-bar
|
|
88559
|
+
'radialbar',
|
|
88560
|
+
// alias for radial-bar
|
|
88561
|
+
'treemap', 'funnel', 'sankey', 'composed', 'waterfall', 'gauge', 'histogram', 'kpi']);
|
|
88562
|
+
|
|
88563
|
+
/**
|
|
88564
|
+
* Routing form of a spec's `type`: trimmed, lowercase, underscore to kebab,
|
|
88565
|
+
* so "Bar", " Donut " and `key_observations` all route.
|
|
88566
|
+
*/
|
|
88567
|
+
function normalizeA2uiType(type) {
|
|
88568
|
+
return String(type || '').trim().toLowerCase().replace(/_/g, '-');
|
|
88569
|
+
}
|
|
88570
|
+
|
|
88571
|
+
// ── Titles ─────────────────────────────────────────────────────────────
|
|
88572
|
+
|
|
88520
88573
|
const SQLISH = /\b(?:field|fields|column|columns|table|tables|sql|summed?|aggregat\w*|averaged?|counted)\b/i;
|
|
88574
|
+
// A dotted identifier like `employees.salary` (table.column).
|
|
88521
88575
|
const DOTTED = /[A-Za-z_]\w*\.[A-Za-z_]\w*/;
|
|
88576
|
+
|
|
88577
|
+
/**
|
|
88578
|
+
* Strip internal DB identifiers from an LLM-generated chart/card title (EV
|
|
88579
|
+
* pie-chart bug, issue 1). The database_query sub-agent sometimes appends how
|
|
88580
|
+
* a value was computed - "... by department (summed from the employees.salary
|
|
88581
|
+
* field)". The prompt forbids this; this is the deterministic guarantee a
|
|
88582
|
+
* table/column name never reaches the user.
|
|
88583
|
+
*
|
|
88584
|
+
* Conservative: only a TRAILING parenthetical that reads as a DB/SQL
|
|
88585
|
+
* annotation is removed (repeatedly, in case several are stacked). A
|
|
88586
|
+
* legitimate "(2024)" or "(YoY)" is kept.
|
|
88587
|
+
*/
|
|
88522
88588
|
function sanitizeChartTitle(title) {
|
|
88523
88589
|
if (typeof title !== 'string') return '';
|
|
88524
88590
|
let t = title.trim();
|
|
88525
88591
|
let prev;
|
|
88526
88592
|
do {
|
|
88527
88593
|
prev = t;
|
|
88528
|
-
t = t.replace(/\s*\(([^()]*)\)\s*$/, (match, inner)
|
|
88594
|
+
t = t.replace(/\s*\(([^()]*)\)\s*$/, function (match, inner) {
|
|
88595
|
+
return SQLISH.test(inner) || DOTTED.test(inner) ? '' : match;
|
|
88596
|
+
}).trim();
|
|
88529
88597
|
} while (t !== prev);
|
|
88530
88598
|
return t;
|
|
88531
88599
|
}
|
|
88532
88600
|
|
|
88601
|
+
// ── Approval cards (EV-4798 MERN-12) ───────────────────────────────────
|
|
88602
|
+
|
|
88603
|
+
const APPROVE_VALUES = new Set(['yes', 'approve', 'approved', 'confirm']);
|
|
88604
|
+
const DECLINE_VALUES = new Set(['no', 'decline', 'declined', 'cancel']);
|
|
88605
|
+
const ID_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/;
|
|
88606
|
+
function cleanId(value) {
|
|
88607
|
+
if (typeof value !== 'string') return null;
|
|
88608
|
+
const id = value.trim();
|
|
88609
|
+
return ID_PATTERN.test(id) ? id : null;
|
|
88610
|
+
}
|
|
88611
|
+
function firstDefined$1(a, b) {
|
|
88612
|
+
return a !== null && a !== undefined ? a : b;
|
|
88613
|
+
}
|
|
88614
|
+
|
|
88615
|
+
/** true / false for a choice the card marks as approve / decline; null when unknown. */
|
|
88616
|
+
function choiceApproves(choice) {
|
|
88617
|
+
if (!choice || typeof choice !== 'object') return null;
|
|
88618
|
+
if (typeof choice.approved === 'boolean') return choice.approved;
|
|
88619
|
+
const value = String(firstDefined$1(choice.value, '')).trim().toLowerCase();
|
|
88620
|
+
if (APPROVE_VALUES.has(value)) return true;
|
|
88621
|
+
if (DECLINE_VALUES.has(value)) return false;
|
|
88622
|
+
return null;
|
|
88623
|
+
}
|
|
88624
|
+
|
|
88533
88625
|
/**
|
|
88534
|
-
*
|
|
88535
|
-
*
|
|
88536
|
-
* a knowledge-base document. A2uiBlock renders `href`/`src` values straight
|
|
88537
|
-
* from that JSON with no filtering - a `javascript:` action URL renders as a
|
|
88538
|
-
* legitimate-looking button that executes attacker JS in the visitor's page
|
|
88539
|
-
* when clicked (an authenticated victim's session cookie, DOM, etc. are all
|
|
88540
|
-
* in scope; no account needed to trigger it).
|
|
88541
|
-
*
|
|
88542
|
-
* Every `href`/`src` sink in A2uiBlock must go through this first. Only
|
|
88543
|
-
* absolute http(s) URLs pass; anything else (javascript:, data:, vbscript:,
|
|
88544
|
-
* a bare relative path, non-string) is dropped rather than rendered.
|
|
88626
|
+
* The `tool_confirmation` for a pick on an approval card, or null when the
|
|
88627
|
+
* card does not carry the held call's id (the text answer then stands alone).
|
|
88545
88628
|
*
|
|
88546
|
-
*
|
|
88547
|
-
*
|
|
88548
|
-
*
|
|
88549
|
-
*
|
|
88550
|
-
*
|
|
88551
|
-
* you change the logic here, change it there too or the build fails.
|
|
88629
|
+
* A Skill tool that needs approval pauses the turn and the runtime draws a
|
|
88630
|
+
* `confirmation` card. The turn contract carries `tool_confirmation`
|
|
88631
|
+
* ({ tool_call_id, invocation_id, approved }), which the runtime prefers
|
|
88632
|
+
* because it names the exact call. Built only when the card carries the id
|
|
88633
|
+
* and the decision can be read from the choice.
|
|
88552
88634
|
*/
|
|
88553
|
-
function
|
|
88554
|
-
|
|
88635
|
+
function toolConfirmationFor(spec, choice) {
|
|
88636
|
+
if (!spec || typeof spec !== 'object') return null;
|
|
88637
|
+
const toolCallId = cleanId(firstDefined$1(spec.tool_call_id, spec.toolCallId));
|
|
88638
|
+
if (!toolCallId) return null;
|
|
88639
|
+
const approved = choiceApproves(choice);
|
|
88640
|
+
if (approved === null) return null;
|
|
88641
|
+
return {
|
|
88642
|
+
tool_call_id: toolCallId,
|
|
88643
|
+
invocation_id: cleanId(firstDefined$1(spec.invocation_id, spec.invocationId)) || '',
|
|
88644
|
+
approved: approved
|
|
88645
|
+
};
|
|
88646
|
+
}
|
|
88647
|
+
|
|
88648
|
+
// ── Links in card text ─────────────────────────────────────────────────
|
|
88649
|
+
|
|
88650
|
+
// `[label](https://...)` or a bare http(s) URL (trailing punctuation is not
|
|
88651
|
+
// part of the URL).
|
|
88652
|
+
const LINK_RE = /\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/g;
|
|
88653
|
+
|
|
88654
|
+
/**
|
|
88655
|
+
* A2UI card text is plain text, but agents put markdown links in it (a
|
|
88656
|
+
* booked meeting's "[Join Google Meet](https://...)"). Splits `text` into
|
|
88657
|
+
* text and link segments, or returns null when nothing in it is a safe link
|
|
88658
|
+
* (the caller then renders the text unchanged). Every href passes safeUrl.
|
|
88659
|
+
*/
|
|
88660
|
+
function linkSegments(text) {
|
|
88661
|
+
if (!text) return null;
|
|
88662
|
+
const s = String(text);
|
|
88663
|
+
const out = [];
|
|
88664
|
+
let last = 0;
|
|
88665
|
+
LINK_RE.lastIndex = 0;
|
|
88666
|
+
let m;
|
|
88667
|
+
while ((m = LINK_RE.exec(s)) !== null) {
|
|
88668
|
+
const idx = m.index;
|
|
88669
|
+
const label = m[1] !== undefined ? m[1] : m[3];
|
|
88670
|
+
const href = safeUrl(m[2] !== undefined ? m[2] : m[3]);
|
|
88671
|
+
if (!href) continue;
|
|
88672
|
+
if (idx > last) out.push({
|
|
88673
|
+
type: 'text',
|
|
88674
|
+
value: s.slice(last, idx)
|
|
88675
|
+
});
|
|
88676
|
+
out.push({
|
|
88677
|
+
type: 'link',
|
|
88678
|
+
label: label,
|
|
88679
|
+
href: href
|
|
88680
|
+
});
|
|
88681
|
+
last = idx + m[0].length;
|
|
88682
|
+
}
|
|
88683
|
+
if (out.length === 0) return null;
|
|
88684
|
+
if (last < s.length) out.push({
|
|
88685
|
+
type: 'text',
|
|
88686
|
+
value: s.slice(last)
|
|
88687
|
+
});
|
|
88688
|
+
return out;
|
|
88689
|
+
}
|
|
88690
|
+
|
|
88691
|
+
// ── Answered state of one-shot cards ───────────────────────────────────
|
|
88692
|
+
//
|
|
88693
|
+
// Cards used to hold "already answered" only in component state, so a reload
|
|
88694
|
+
// rendered an answered "Confirm booking" card clickable again and a second
|
|
88695
|
+
// click risked a double booking. The conversation history is the durable
|
|
88696
|
+
// record: a card is consumed once ANY delivered user turn follows the
|
|
88697
|
+
// assistant message that carried it, and that turn is the recorded answer.
|
|
88698
|
+
|
|
88699
|
+
function normAnswer(v) {
|
|
88700
|
+
return String(firstDefined$1(v, '')).trim().toLowerCase();
|
|
88701
|
+
}
|
|
88702
|
+
|
|
88703
|
+
/**
|
|
88704
|
+
* Which choice the recorded answer picked, matched on the choice's `value`
|
|
88705
|
+
* or `label` (surfaces send `value || label`). null when unanswered or when
|
|
88706
|
+
* the user moved on by typing something else.
|
|
88707
|
+
*/
|
|
88708
|
+
function matchAnsweredChoice(choices, answer) {
|
|
88709
|
+
if (answer === undefined || answer === null) return null;
|
|
88710
|
+
const a = normAnswer(answer);
|
|
88711
|
+
if (!a) return null;
|
|
88712
|
+
const list = choices || [];
|
|
88713
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
88714
|
+
const c = list[i];
|
|
88715
|
+
if (normAnswer(c ? c.value : undefined) === a || normAnswer(c ? c.label : undefined) === a) {
|
|
88716
|
+
return String(firstDefined$1(firstDefined$1(c.value, c.label), ''));
|
|
88717
|
+
}
|
|
88718
|
+
}
|
|
88719
|
+
return null;
|
|
88555
88720
|
}
|
|
88556
88721
|
|
|
88557
88722
|
/**
|
|
@@ -88569,35 +88734,145 @@
|
|
|
88569
88734
|
};
|
|
88570
88735
|
}
|
|
88571
88736
|
|
|
88572
|
-
|
|
88737
|
+
/**
|
|
88738
|
+
* Table-cell and label formatting shared by the three A2UI renderers - the
|
|
88739
|
+
* agent-sdk, the widget and the frontend Testing Lab (EV-4798 MERN-19). Each
|
|
88740
|
+
* used to carry a copy commented "kept in lockstep", so Testing Lab, the
|
|
88741
|
+
* installed widget and the SDK render DB tables identically only for as long
|
|
88742
|
+
* as nobody forgot one.
|
|
88743
|
+
*
|
|
88744
|
+
* Rules: ISO date/datetime -> "Dec 9, 2021"; a BARE numeric string in a
|
|
88745
|
+
* money-named column -> comma-grouped WITHOUT synthesizing a currency symbol
|
|
88746
|
+
* (the DB is the source of truth for "$"); a value already carrying a symbol,
|
|
88747
|
+
* or any non-currency column, passes through verbatim or prettified.
|
|
88748
|
+
*
|
|
88749
|
+
* Plain JavaScript (types in cellFormat.d.ts), conservative syntax: the
|
|
88750
|
+
* widget's Rollup/Babel build compiles neither TypeScript nor this file.
|
|
88751
|
+
*/
|
|
88752
|
+
|
|
88753
|
+
const MONTH_NAMES$1 = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
88754
|
+
|
|
88755
|
+
/**
|
|
88756
|
+
* Humanise a key: snake / kebab to spaces, camelCase split, each lower-case
|
|
88757
|
+
* word capitalised ("total_expenses" -> "Total Expenses").
|
|
88758
|
+
*
|
|
88759
|
+
* Only a column-style value is reworded: one with an underscore
|
|
88760
|
+
* (office_supplies), one without capitals (kebab-case, all-lower words) or a
|
|
88761
|
+
* camelCase key. A value written with capitals is a name and stays as written
|
|
88762
|
+
* ("DD Sports", "Republic TV", "GEC"), as does a word written with capitals
|
|
88763
|
+
* inside a key (GRP_pre -> "GRP Pre"). Lower-casing every word but its first
|
|
88764
|
+
* letter read those channels as "Dd Sports", "Republic Tv" and "Gec" (Media
|
|
88765
|
+
* Prowess, Oct 2026).
|
|
88766
|
+
*/
|
|
88767
|
+
function prettifyLabel(value) {
|
|
88768
|
+
if (value === null || value === undefined) return '';
|
|
88769
|
+
const s = String(value).replace(/\s+/g, ' ').trim();
|
|
88770
|
+
if (!s) return '';
|
|
88771
|
+
if (!isColumnStyle(s)) return s;
|
|
88772
|
+
return s.replace(/[_\-]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/\s+/g, ' ').trim().split(' ').map(function (w) {
|
|
88773
|
+
return w === w.toLowerCase() ? w.charAt(0).toUpperCase() + w.slice(1) : w;
|
|
88774
|
+
}).join(' ');
|
|
88775
|
+
}
|
|
88776
|
+
|
|
88777
|
+
// A camelCase key ("camelCaseKey", "totalSpend"): two lower-case letters
|
|
88778
|
+
// before the first capital, so names such as "iPhone", "eBay" and "kWh" stay
|
|
88779
|
+
// as written.
|
|
88780
|
+
const CAMEL_CASE_KEY_RE = /^[a-z]{2}[a-z0-9]*[A-Z][A-Za-z0-9]*$/;
|
|
88781
|
+
function isColumnStyle(s) {
|
|
88782
|
+
return s.indexOf('_') !== -1 || s === s.toLowerCase() || CAMEL_CASE_KEY_RE.test(s);
|
|
88783
|
+
}
|
|
88784
|
+
|
|
88785
|
+
// The leading `(^|[_\W])` stands in for `\b`, which does not fire next to `_`
|
|
88786
|
+
// (so "total_expenses" still matches "expense").
|
|
88787
|
+
const CURRENCY_KEY_RE = /(^|[_\W])(price|salary|cost|amount|revenue|expense|total|value|budget|spend|income|sale|balance)/i;
|
|
88788
|
+
|
|
88789
|
+
/** Does this column name suggest money? Conservative: false negatives are fine. */
|
|
88790
|
+
function isCurrencyKey(key) {
|
|
88791
|
+
if (typeof key !== 'string') return false;
|
|
88792
|
+
return CURRENCY_KEY_RE.test(key);
|
|
88793
|
+
}
|
|
88794
|
+
const ISO_DATETIME_CELL_RE = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
88795
|
+
|
|
88796
|
+
/**
|
|
88797
|
+
* "Dec 9, 2021" for an ISO date, "Dec 9, 2021, 2:30 PM" when a non-midnight
|
|
88798
|
+
* time is present; null when the value is not an ISO date/datetime.
|
|
88799
|
+
*/
|
|
88800
|
+
function formatDateCell(value) {
|
|
88801
|
+
if (typeof value !== 'string') return null;
|
|
88802
|
+
const trimmed = value.trim();
|
|
88803
|
+
if (!ISO_DATETIME_CELL_RE.test(trimmed)) return null;
|
|
88804
|
+
// A date-time string with no trailing 'Z'/offset is parsed by `new Date()`
|
|
88805
|
+
// as LOCAL time (ECMA-262), but it is read back below via UTC getters - so a
|
|
88806
|
+
// timezone-less value is normalised to UTC first, or a non-UTC runtime
|
|
88807
|
+
// renders the wrong calendar day/time (EV-2783 follow-up). Date-only
|
|
88808
|
+
// strings are already parsed as UTC midnight per spec.
|
|
88809
|
+
const hasTime = /[T ]\d{2}:\d{2}/.test(trimmed);
|
|
88810
|
+
const hasTz = /(Z|[+-]\d{2}:?\d{2})$/.test(trimmed);
|
|
88811
|
+
const parseable = hasTime && !hasTz ? trimmed.replace(' ', 'T') + 'Z' : trimmed;
|
|
88812
|
+
const d = new Date(parseable);
|
|
88813
|
+
if (isNaN(d.getTime())) return null;
|
|
88814
|
+
const hasNonMidnightTime = hasTime && (d.getUTCHours() !== 0 || d.getUTCMinutes() !== 0 || d.getUTCSeconds() !== 0);
|
|
88815
|
+
const dateStr = MONTH_NAMES$1[d.getUTCMonth()] + ' ' + d.getUTCDate() + ', ' + d.getUTCFullYear();
|
|
88816
|
+
if (!hasNonMidnightTime) return dateStr;
|
|
88817
|
+
const h = d.getUTCHours();
|
|
88818
|
+
const ampm = h >= 12 ? 'PM' : 'AM';
|
|
88819
|
+
const h12 = h % 12 || 12;
|
|
88820
|
+
const mm = String(d.getUTCMinutes()).padStart(2, '0');
|
|
88821
|
+
return dateStr + ', ' + h12 + ':' + mm + ' ' + ampm;
|
|
88822
|
+
}
|
|
88823
|
+
|
|
88824
|
+
// Lowercase ASCII tokens joined by underscores (one or more joins). IDs,
|
|
88825
|
+
// paths, emails, slugs and camelCase do not match and pass through verbatim.
|
|
88826
|
+
const SNAKE_CASE_CATEGORY_RE = /^[a-z]+(_[a-z]+)+$/;
|
|
88827
|
+
|
|
88828
|
+
/**
|
|
88829
|
+
* Show a snake_case category value ("office_supplies") as "Office Supplies";
|
|
88830
|
+
* everything else (and anything over 40 characters) is left as it is.
|
|
88831
|
+
*/
|
|
88832
|
+
function prettifyCategoryCell(value) {
|
|
88833
|
+
if (typeof value !== 'string') return String(value === null || value === undefined ? '' : value);
|
|
88834
|
+
if (value.length > 40) return value;
|
|
88835
|
+
if (!SNAKE_CASE_CATEGORY_RE.test(value)) return value;
|
|
88836
|
+
return prettifyLabel(value);
|
|
88837
|
+
}
|
|
88838
|
+
|
|
88839
|
+
/** Comma-group a number for display: integers as-is, others to 2 decimals. */
|
|
88840
|
+
function commaGroup(n) {
|
|
88841
|
+
return Number.isInteger(n) ? n.toLocaleString('en-US') : Number(n.toFixed(2)).toLocaleString('en-US');
|
|
88842
|
+
}
|
|
88843
|
+
const BARE_NUMBER_RE = /^-?\d+(\.\d+)?$/;
|
|
88844
|
+
|
|
88845
|
+
/**
|
|
88846
|
+
* Comma-group a BARE numeric string in a currency-named column, without
|
|
88847
|
+
* adding a currency symbol. null when not applicable (not a string, not a
|
|
88848
|
+
* money column, or the value already carries a symbol), so callers fall
|
|
88849
|
+
* through to their next rule.
|
|
88850
|
+
*/
|
|
88851
|
+
function formatCurrencyCellString(value, key) {
|
|
88852
|
+
if (typeof value !== 'string' || !isCurrencyKey(key)) return null;
|
|
88853
|
+
const s = value.trim();
|
|
88854
|
+
if (!BARE_NUMBER_RE.test(s)) return null;
|
|
88855
|
+
const n = Number(s);
|
|
88856
|
+
if (!Number.isFinite(n)) return null;
|
|
88857
|
+
return commaGroup(n);
|
|
88858
|
+
}
|
|
88859
|
+
|
|
88573
88860
|
const LINK_STYLE = {
|
|
88574
88861
|
color: '#1d4ed8',
|
|
88575
88862
|
textDecoration: 'underline',
|
|
88576
88863
|
overflowWrap: 'anywhere'
|
|
88577
88864
|
};
|
|
88578
88865
|
function linkifyText(text) {
|
|
88579
|
-
|
|
88580
|
-
|
|
88581
|
-
let last = 0;
|
|
88866
|
+
const segments = linkSegments(text);
|
|
88867
|
+
if (!segments) return text;
|
|
88582
88868
|
let key = 0;
|
|
88583
|
-
|
|
88584
|
-
|
|
88585
|
-
|
|
88586
|
-
|
|
88587
|
-
|
|
88588
|
-
|
|
88589
|
-
|
|
88590
|
-
href: url,
|
|
88591
|
-
target: "_blank",
|
|
88592
|
-
rel: "noopener noreferrer",
|
|
88593
|
-
style: LINK_STYLE,
|
|
88594
|
-
children: label
|
|
88595
|
-
}, key++));
|
|
88596
|
-
last = idx + m[0].length;
|
|
88597
|
-
}
|
|
88598
|
-
if (out.length === 0) return text;
|
|
88599
|
-
if (last < text.length) out.push(text.slice(last));
|
|
88600
|
-
return out;
|
|
88869
|
+
return segments.map(seg => seg.type === 'link' ? /*#__PURE__*/jsxRuntimeExports.jsx("a", {
|
|
88870
|
+
href: seg.href,
|
|
88871
|
+
target: "_blank",
|
|
88872
|
+
rel: "noopener noreferrer",
|
|
88873
|
+
style: LINK_STYLE,
|
|
88874
|
+
children: seg.label
|
|
88875
|
+
}, key++) : seg.value);
|
|
88601
88876
|
}
|
|
88602
88877
|
|
|
88603
88878
|
// Kept in its own tiny module (not A2uiBlock.jsx): MessageList imports it
|
|
@@ -88611,14 +88886,6 @@
|
|
|
88611
88886
|
// "Confirm booking". Context rather than a prop so it does not have to be
|
|
88612
88887
|
// threaded through MessageBubble -> StreamingMarkdown -> MarkdownRenderer.
|
|
88613
88888
|
const A2uiAnsweredContext = /*#__PURE__*/React.createContext(undefined);
|
|
88614
|
-
const _normAnswer = v => String(v ?? '').trim().toLowerCase();
|
|
88615
|
-
function matchAnsweredChoice(choices, answer) {
|
|
88616
|
-
if (answer === undefined || answer === null) return null;
|
|
88617
|
-
const a = _normAnswer(answer);
|
|
88618
|
-
if (!a) return null;
|
|
88619
|
-
const hit = (choices || []).find(c => _normAnswer(c?.value) === a || _normAnswer(c?.label) === a);
|
|
88620
|
-
return hit ? String(hit.value ?? hit.label ?? '') : null;
|
|
88621
|
-
}
|
|
88622
88889
|
|
|
88623
88890
|
/**
|
|
88624
88891
|
* A2uiBlock - render `eshal-ui` / `eshal-chart` / `chart` fence JSON as a
|
|
@@ -88647,6 +88914,10 @@
|
|
|
88647
88914
|
*/
|
|
88648
88915
|
|
|
88649
88916
|
|
|
88917
|
+
// The chart-family types the dispatcher routes to <Chart>, from the shared
|
|
88918
|
+
// A2UI registry (EV-4798 MERN-19).
|
|
88919
|
+
const CHART_TYPES = new Set(A2UI_CHART_TYPES);
|
|
88920
|
+
|
|
88650
88921
|
// Card images (banner, list item, avatar, gallery) go through the chat image
|
|
88651
88922
|
// policy too. Unchanged while it is off, which is the default.
|
|
88652
88923
|
const cardImageSrc = u => policedImageSrc(safeUrl(u));
|
|
@@ -88667,10 +88938,10 @@
|
|
|
88667
88938
|
};
|
|
88668
88939
|
// Normalize underscore_case → kebab-case so the agent can emit either
|
|
88669
88940
|
// form. Both `key_observations` and `key-observations` route here.
|
|
88670
|
-
const t =
|
|
88941
|
+
const t = normalizeA2uiType(spec.type);
|
|
88671
88942
|
|
|
88672
|
-
// Charts (incl. graceful fallback for chart-shaped data with unknown type)
|
|
88673
|
-
|
|
88943
|
+
// Charts (incl. graceful fallback for chart-shaped data with unknown type).
|
|
88944
|
+
// CHART_TYPES is the shared chat core's registry (module scope, below the imports).
|
|
88674
88945
|
|
|
88675
88946
|
// ── On-demand analytical insight cards (12-06-2026) ──
|
|
88676
88947
|
// Six types the database_query agent emits when the user explicitly
|
|
@@ -89507,36 +89778,43 @@
|
|
|
89507
89778
|
}
|
|
89508
89779
|
if (field.type === 'radio') {
|
|
89509
89780
|
const opts = Array.isArray(field.options) ? field.options : [];
|
|
89510
|
-
return
|
|
89511
|
-
|
|
89512
|
-
|
|
89513
|
-
|
|
89514
|
-
children: [/*#__PURE__*/jsxRuntimeExports.jsxs("div", {
|
|
89515
|
-
style: labelStyle,
|
|
89516
|
-
children: [field.label, field.required ? ' *' : '']
|
|
89517
|
-
}), opts.map(o => /*#__PURE__*/jsxRuntimeExports.jsxs("label", {
|
|
89781
|
+
return (
|
|
89782
|
+
/*#__PURE__*/
|
|
89783
|
+
// EV-4798 (MERN-21): the visible label names the radio group.
|
|
89784
|
+
jsxRuntimeExports.jsxs("div", {
|
|
89518
89785
|
style: {
|
|
89519
|
-
|
|
89520
|
-
|
|
89521
|
-
|
|
89522
|
-
|
|
89523
|
-
|
|
89524
|
-
|
|
89525
|
-
|
|
89526
|
-
|
|
89527
|
-
|
|
89528
|
-
|
|
89529
|
-
|
|
89530
|
-
|
|
89531
|
-
|
|
89532
|
-
|
|
89533
|
-
|
|
89534
|
-
|
|
89535
|
-
|
|
89536
|
-
|
|
89537
|
-
|
|
89538
|
-
|
|
89539
|
-
|
|
89786
|
+
marginBottom: 10
|
|
89787
|
+
},
|
|
89788
|
+
role: "radiogroup",
|
|
89789
|
+
"aria-labelledby": `${autoId}-label`,
|
|
89790
|
+
children: [/*#__PURE__*/jsxRuntimeExports.jsxs("div", {
|
|
89791
|
+
id: `${autoId}-label`,
|
|
89792
|
+
style: labelStyle,
|
|
89793
|
+
children: [field.label, field.required ? ' *' : '']
|
|
89794
|
+
}), opts.map(o => /*#__PURE__*/jsxRuntimeExports.jsxs("label", {
|
|
89795
|
+
style: {
|
|
89796
|
+
display: 'flex',
|
|
89797
|
+
alignItems: 'center',
|
|
89798
|
+
gap: 6,
|
|
89799
|
+
fontSize: 12,
|
|
89800
|
+
marginBottom: 2
|
|
89801
|
+
},
|
|
89802
|
+
children: [/*#__PURE__*/jsxRuntimeExports.jsx("input", {
|
|
89803
|
+
type: "radio",
|
|
89804
|
+
name: autoId,
|
|
89805
|
+
checked: value === o.value,
|
|
89806
|
+
onChange: () => onChange(o.value)
|
|
89807
|
+
}), o.label]
|
|
89808
|
+
}, o.value)), error && /*#__PURE__*/jsxRuntimeExports.jsx("div", {
|
|
89809
|
+
style: {
|
|
89810
|
+
fontSize: 11,
|
|
89811
|
+
color: '#ef4444',
|
|
89812
|
+
marginTop: 2
|
|
89813
|
+
},
|
|
89814
|
+
children: error
|
|
89815
|
+
})]
|
|
89816
|
+
})
|
|
89817
|
+
);
|
|
89540
89818
|
}
|
|
89541
89819
|
if (field.type === 'textarea') {
|
|
89542
89820
|
return /*#__PURE__*/jsxRuntimeExports.jsxs("div", {
|
|
@@ -89769,7 +90047,14 @@
|
|
|
89769
90047
|
if (sentRef.current || locked) return;
|
|
89770
90048
|
sentRef.current = true;
|
|
89771
90049
|
setLocalChosen(String(value));
|
|
89772
|
-
|
|
90050
|
+
// EV-4798 MERN-12: a card that carries the held call's id is also
|
|
90051
|
+
// answered by that id (utils/toolConfirmation.js); the value text
|
|
90052
|
+
// still goes as the message either way.
|
|
90053
|
+
const choice = buttons.find(b => String(b?.value || b?.label || '') === String(value));
|
|
90054
|
+
const toolConfirmation = toolConfirmationFor(spec, choice);
|
|
90055
|
+
if (toolConfirmation) onButtonClick(value, label, {
|
|
90056
|
+
toolConfirmation
|
|
90057
|
+
});else onButtonClick(value, label);
|
|
89773
90058
|
} : undefined;
|
|
89774
90059
|
return /*#__PURE__*/jsxRuntimeExports.jsxs("div", {
|
|
89775
90060
|
style: card(isDark),
|
|
@@ -90525,46 +90810,14 @@
|
|
|
90525
90810
|
}
|
|
90526
90811
|
|
|
90527
90812
|
// ── Bug 1 + Bug 3 helpers (2026-06-16) ───────────────────────────────────
|
|
90528
|
-
//
|
|
90529
|
-
// chartShared.tsx.
|
|
90530
|
-
//
|
|
90531
|
-
//
|
|
90532
|
-
// the frontend copy in __tests__/chartShared.test.ts - keep this code
|
|
90533
|
-
// in lockstep with that file when either is touched. Regex boundaries
|
|
90534
|
-
// use `(^|[_\W])` instead of `\b` because JS regex `\b` doesn't fire
|
|
90535
|
-
// next to `_` (underscore is a word char), so "total_expenses" with
|
|
90536
|
-
// `\bexpense\b` would miss the match.
|
|
90813
|
+
// Chart value and axis-tick formatting, ported from packages/frontend/src/
|
|
90814
|
+
// components/charts/eshal-chart/chartShared.tsx. The label and currency-key
|
|
90815
|
+
// helpers they build on (prettifyLabel, isCurrencyKey) are the shared chat
|
|
90816
|
+
// core's (EV-4798 MERN-19); the chart-only rules below stay with the widget.
|
|
90537
90817
|
|
|
90538
90818
|
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
90539
|
-
const CURRENCY_KEY_RE = /(^|[_\W])(price|salary|cost|amount|revenue|expense|total|value|budget|spend|income|sale|balance)/i;
|
|
90540
90819
|
const MONTH_KEY_RE = /(^|[_\W])month/i;
|
|
90541
90820
|
|
|
90542
|
-
// Only a column-style value is reworded: one with an underscore
|
|
90543
|
-
// (office_supplies), one without capitals (kebab-case, all-lower words)
|
|
90544
|
-
// or a camelCase key. A value written with capitals is a name and stays as
|
|
90545
|
-
// written ("DD Sports", "Republic TV", "GEC"), as does a word written with
|
|
90546
|
-
// capitals inside a key (GRP_pre -> "GRP Pre"). Lower-casing every word but
|
|
90547
|
-
// its first letter read those channels as "Dd Sports", "Republic Tv" and
|
|
90548
|
-
// "Gec" (Media Prowess, Oct 2026).
|
|
90549
|
-
function prettifyLabel(value) {
|
|
90550
|
-
if (value === null || value === undefined) return '';
|
|
90551
|
-
const s = String(value).replace(/\s+/g, ' ').trim();
|
|
90552
|
-
if (!s) return '';
|
|
90553
|
-
if (!isColumnStyle(s)) return s;
|
|
90554
|
-
return s.replace(/[_\-]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/\s+/g, ' ').trim().split(' ').map(w => w === w.toLowerCase() ? w.charAt(0).toUpperCase() + w.slice(1) : w).join(' ');
|
|
90555
|
-
}
|
|
90556
|
-
|
|
90557
|
-
// A camelCase key ("camelCaseKey", "totalSpend"): two lower-case letters
|
|
90558
|
-
// before the first capital, so names such as "iPhone", "eBay" and "kWh"
|
|
90559
|
-
// stay as written.
|
|
90560
|
-
const CAMEL_CASE_KEY_RE = /^[a-z]{2}[a-z0-9]*[A-Z][A-Za-z0-9]*$/;
|
|
90561
|
-
function isColumnStyle(s) {
|
|
90562
|
-
return s.includes('_') || s === s.toLowerCase() || CAMEL_CASE_KEY_RE.test(s);
|
|
90563
|
-
}
|
|
90564
|
-
function isCurrencyKey(key) {
|
|
90565
|
-
return typeof key === 'string' && CURRENCY_KEY_RE.test(key);
|
|
90566
|
-
}
|
|
90567
|
-
|
|
90568
90821
|
// ISO date / datetime prefix - matches the typical SQL output the
|
|
90569
90822
|
// agent's tools produce when GROUP BY-ing by month
|
|
90570
90823
|
// (`DATE_TRUNC('month', col)` → `YYYY-MM-DD`, or `to_char(..,'YYYY-MM')`
|
|
@@ -90597,80 +90850,25 @@
|
|
|
90597
90850
|
return prettifyLabel(value);
|
|
90598
90851
|
}
|
|
90599
90852
|
|
|
90600
|
-
//
|
|
90601
|
-
// (
|
|
90602
|
-
//
|
|
90603
|
-
//
|
|
90604
|
-
//
|
|
90605
|
-
//
|
|
90606
|
-
// Goal: when a category column shows `office_supplies` next to a chart
|
|
90607
|
-
// whose X-axis (via prettifyLabel) already renders "Office Supplies",
|
|
90608
|
-
// the table should agree. Conservative - only lowercase ASCII tokens
|
|
90609
|
-
// joined by underscores (≤ 40 chars) qualify, so IDs/paths/emails/
|
|
90610
|
-
// slugs/camelCase pass through verbatim.
|
|
90611
|
-
const SNAKE_CASE_CATEGORY_RE = /^[a-z]+(_[a-z]+)+$/;
|
|
90612
|
-
function prettifyCategoryCell(value) {
|
|
90613
|
-
if (typeof value !== 'string') return String(value ?? '');
|
|
90614
|
-
if (value.length > 40) return value;
|
|
90615
|
-
if (!SNAKE_CASE_CATEGORY_RE.test(value)) return value;
|
|
90616
|
-
return prettifyLabel(value);
|
|
90617
|
-
}
|
|
90618
|
-
|
|
90619
|
-
// Numeric string under a money column, e.g. "81,200" / "105444" / "-1,250.50".
|
|
90620
|
-
// Table-cell formatting, kept IN LOCKSTEP with the Testing-Lab (frontend
|
|
90621
|
-
// chartShared.tsx: formatDateCell / formatCurrencyCellString) and SDK copies so
|
|
90622
|
-
// all three surfaces render DB tables identically. Two rules:
|
|
90623
|
-
// 1. ISO date/datetime → "Dec 9, 2021" (parity with Testing Lab).
|
|
90624
|
-
// 2. Bare numeric string in a money-named column → comma-group, and NEVER
|
|
90625
|
-
// synthesize a currency symbol - the DB is the source of truth for "$".
|
|
90626
|
-
// A value that already carries a symbol ("$71000", "₹560") fails
|
|
90627
|
-
// _BARE_NUMBER_RE and is passed through verbatim, so we don't double-format.
|
|
90628
|
-
const _BARE_NUMBER_RE = /^-?\d+(\.\d+)?$/;
|
|
90629
|
-
const _MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
90630
|
-
const _ISO_DATETIME_CELL_RE = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
90631
|
-
function _formatDateCell(value) {
|
|
90632
|
-
if (typeof value !== 'string') return null;
|
|
90633
|
-
const trimmed = value.trim();
|
|
90634
|
-
if (!_ISO_DATETIME_CELL_RE.test(trimmed)) return null;
|
|
90635
|
-
// A date-time string with no trailing 'Z'/offset is parsed by `new
|
|
90636
|
-
// Date()` as LOCAL time (ECMA-262), but we read it back below via UTC
|
|
90637
|
-
// getters - so a timezone-less value must be normalized to UTC first,
|
|
90638
|
-
// or a non-UTC runtime renders the wrong calendar day/time (EV-2783
|
|
90639
|
-
// follow-up). Date-only strings (no time part) are already parsed as
|
|
90640
|
-
// UTC midnight per spec and need no change.
|
|
90641
|
-
const hasTime = /[T ]\d{2}:\d{2}/.test(trimmed);
|
|
90642
|
-
const hasTz = /(Z|[+-]\d{2}:?\d{2})$/.test(trimmed);
|
|
90643
|
-
const parseable = hasTime && !hasTz ? `${trimmed.replace(' ', 'T')}Z` : trimmed;
|
|
90644
|
-
const d = new Date(parseable);
|
|
90645
|
-
if (isNaN(d.getTime())) return null;
|
|
90646
|
-
const hasNonMidnightTime = hasTime && (d.getUTCHours() !== 0 || d.getUTCMinutes() !== 0 || d.getUTCSeconds() !== 0);
|
|
90647
|
-
const dateStr = `${_MONTH_NAMES[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
|
|
90648
|
-
if (!hasNonMidnightTime) return dateStr;
|
|
90649
|
-
const h = d.getUTCHours();
|
|
90650
|
-
const ampm = h >= 12 ? 'PM' : 'AM';
|
|
90651
|
-
const h12 = h % 12 || 12;
|
|
90652
|
-
const mm = String(d.getUTCMinutes()).padStart(2, '0');
|
|
90653
|
-
return `${dateStr}, ${h12}:${mm} ${ampm}`;
|
|
90654
|
-
}
|
|
90655
|
-
function _commaGroup(n) {
|
|
90656
|
-
return Number.isInteger(n) ? n.toLocaleString('en-US') : Number(n.toFixed(2)).toLocaleString('en-US');
|
|
90657
|
-
}
|
|
90853
|
+
// Table cells: the date, money-string and snake_case-category rules are the
|
|
90854
|
+
// shared chat core's (packages/shared/src/chatCore/cellFormat.js, EV-4798
|
|
90855
|
+
// MERN-19), so Testing Lab, the widget and the SDK render DB tables
|
|
90856
|
+
// identically by construction. formatCell keeps the widget's own handling of
|
|
90857
|
+
// raw numbers and objects.
|
|
90658
90858
|
function formatCell(v, columnKey) {
|
|
90659
90859
|
if (v == null) return '';
|
|
90660
90860
|
if (typeof v === 'number') {
|
|
90661
90861
|
// Comma-group; NEVER synthesize a currency symbol (DB owns symbols).
|
|
90662
|
-
return
|
|
90862
|
+
return commaGroup(v);
|
|
90663
90863
|
}
|
|
90664
90864
|
if (typeof v === 'object') return JSON.stringify(v);
|
|
90665
90865
|
const s = String(v).trim();
|
|
90666
90866
|
// 1. ISO date/datetime → friendly date.
|
|
90667
|
-
const dateCell =
|
|
90867
|
+
const dateCell = formatDateCell(s);
|
|
90668
90868
|
if (dateCell) return dateCell;
|
|
90669
90869
|
// 2. Bare numeric string in a money column → comma-group, NO "$".
|
|
90670
|
-
|
|
90671
|
-
|
|
90672
|
-
if (Number.isFinite(n)) return _commaGroup(n);
|
|
90673
|
-
}
|
|
90870
|
+
const money = formatCurrencyCellString(s, columnKey);
|
|
90871
|
+
if (money !== null) return money;
|
|
90674
90872
|
// 3. Everything else: prettify snake_case labels; IDs/paths/emails/slugs and
|
|
90675
90873
|
// any DB value already carrying a symbol pass through verbatim.
|
|
90676
90874
|
return prettifyCategoryCell(v);
|
|
@@ -91648,48 +91846,46 @@
|
|
|
91648
91846
|
}
|
|
91649
91847
|
|
|
91650
91848
|
/**
|
|
91651
|
-
*
|
|
91652
|
-
* segments, so the renderer can swap each cluster for an inline
|
|
91653
|
-
* `<CitationPill>` while leaving prose intact.
|
|
91849
|
+
* Inline citation core shared by every chat renderer (EV-4798 MERN-19).
|
|
91654
91850
|
*
|
|
91655
|
-
*
|
|
91656
|
-
*
|
|
91657
|
-
*
|
|
91851
|
+
* Before this file the same logic lived three times - in the agent-sdk, the
|
|
91852
|
+
* widget and the frontend Testing Lab - each copy headed "keep in sync". It
|
|
91853
|
+
* owns everything about an inline `[N]` citation that is not presentation:
|
|
91854
|
+
*
|
|
91855
|
+
* - parseCitationClusters: assistant text -> text and citation-cluster
|
|
91856
|
+
* segments (cluster rule, Devanagari digits, punctuation reorder).
|
|
91857
|
+
* - rehypeCitations: the rehype plugin that swaps `[N]` markers in HAST
|
|
91858
|
+
* text nodes for `<span data-eshal-citation="1,3">` placeholders.
|
|
91859
|
+
* - the CitationPill's source resolution: which source cards a cluster
|
|
91860
|
+
* points at, de-duplicated, plus the pill's label / file / key rules.
|
|
91658
91861
|
*
|
|
91659
|
-
*
|
|
91660
|
-
* the
|
|
91862
|
+
* Each surface keeps its own CitationPill component (lucide icons on the
|
|
91863
|
+
* widget, inline SVG in the SDK, Tailwind in the frontend). The three pills
|
|
91864
|
+
* historically read source ids from slightly different fields (`source_id`
|
|
91865
|
+
* vs `sourceid`), so the id-reading helpers take the surface's field list
|
|
91866
|
+
* rather than silently unifying it - unifying would change which sources a
|
|
91867
|
+
* surface treats as files.
|
|
91661
91868
|
*
|
|
91662
|
-
*
|
|
91663
|
-
*
|
|
91664
|
-
*
|
|
91665
|
-
*
|
|
91666
|
-
*
|
|
91667
|
-
* test asserts the same input → same segment list on every surface.
|
|
91869
|
+
* Plain JavaScript with no imports, on purpose: the widget's Rollup/Babel
|
|
91870
|
+
* build compiles no TypeScript from another package, and the published SDK
|
|
91871
|
+
* and widget bundle this file directly. Types live in citations.d.ts.
|
|
91872
|
+
* Conservative syntax (no `??`, no `?.`): files from another package are not
|
|
91873
|
+
* run through the widget's Babel preset, so they reach its bundle as written.
|
|
91668
91874
|
*/
|
|
91669
91875
|
|
|
91670
|
-
// Latin `[N]` / `[N,M]` markers, plus Devanagari digits
|
|
91671
|
-
//
|
|
91672
|
-
// `[label](url)`.
|
|
91876
|
+
// Latin `[N]` / `[N,M]` markers, plus Devanagari digits. The negative
|
|
91877
|
+
// lookahead `(?!\()` excludes Markdown link syntax `[label](url)`.
|
|
91673
91878
|
const MARKER_RE = /\[([\d०-९][\d०-९\s,]*)\](?!\()/g;
|
|
91674
|
-
const DEV_DIGIT_MAP = {
|
|
91675
|
-
"०": "0",
|
|
91676
|
-
"१": "1",
|
|
91677
|
-
"२": "2",
|
|
91678
|
-
"३": "3",
|
|
91679
|
-
"४": "4",
|
|
91680
|
-
"५": "5",
|
|
91681
|
-
"६": "6",
|
|
91682
|
-
"७": "7",
|
|
91683
|
-
"८": "8",
|
|
91684
|
-
"९": "9"
|
|
91685
|
-
};
|
|
91686
91879
|
function normalizeDigits(s) {
|
|
91687
|
-
return s.replace(/[०-९]/g,
|
|
91880
|
+
return s.replace(/[०-९]/g, function (d) {
|
|
91881
|
+
return String(d.charCodeAt(0) - 0x0966);
|
|
91882
|
+
});
|
|
91688
91883
|
}
|
|
91689
91884
|
function parseIndicesInside(inner) {
|
|
91690
91885
|
const out = [];
|
|
91691
|
-
|
|
91692
|
-
|
|
91886
|
+
const parts = normalizeDigits(inner).split(',');
|
|
91887
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
91888
|
+
const t = parts[i].trim();
|
|
91693
91889
|
if (/^\d+$/.test(t)) {
|
|
91694
91890
|
const n = parseInt(t, 10);
|
|
91695
91891
|
if (n >= 1) out.push(n);
|
|
@@ -91700,7 +91896,8 @@
|
|
|
91700
91896
|
function dedupPreserveOrder(nums) {
|
|
91701
91897
|
const seen = new Set();
|
|
91702
91898
|
const out = [];
|
|
91703
|
-
for (
|
|
91899
|
+
for (let i = 0; i < nums.length; i += 1) {
|
|
91900
|
+
const n = nums[i];
|
|
91704
91901
|
if (!seen.has(n)) {
|
|
91705
91902
|
seen.add(n);
|
|
91706
91903
|
out.push(n);
|
|
@@ -91716,8 +91913,18 @@
|
|
|
91716
91913
|
// Markdown links `[label](url)` untouched.
|
|
91717
91914
|
const CITE_PUNCT_RE = /(\s*)((?:\[[\d०-९][\d०-९\s,]*\](?!\()\s*)+)([.!?])/g;
|
|
91718
91915
|
function reorderCitationPunctuation(text) {
|
|
91719
|
-
return text.replace(CITE_PUNCT_RE, (_all, _lead, cluster, punct)
|
|
91916
|
+
return text.replace(CITE_PUNCT_RE, function (_all, _lead, cluster, punct) {
|
|
91917
|
+
return punct + ' ' + cluster.replace(/\s+$/, '');
|
|
91918
|
+
});
|
|
91720
91919
|
}
|
|
91920
|
+
|
|
91921
|
+
/**
|
|
91922
|
+
* Parse an assistant message body into TEXT and CITATION-CLUSTER segments.
|
|
91923
|
+
*
|
|
91924
|
+
* Cluster rule: consecutive `[N]` markers separated only by whitespace
|
|
91925
|
+
* (`[1][2]`, `[1] [2]`, `[1,3]` - same intent) collapse into ONE cluster.
|
|
91926
|
+
* A break in-between (`[1] some text [2]`) yields two separate clusters.
|
|
91927
|
+
*/
|
|
91721
91928
|
function parseCitationClusters(text) {
|
|
91722
91929
|
if (!text) return [];
|
|
91723
91930
|
text = reorderCitationPunctuation(text);
|
|
@@ -91730,122 +91937,228 @@
|
|
|
91730
91937
|
markers.push({
|
|
91731
91938
|
start: m.index,
|
|
91732
91939
|
end: MARKER_RE.lastIndex,
|
|
91733
|
-
indices
|
|
91940
|
+
indices: indices
|
|
91734
91941
|
});
|
|
91735
91942
|
}
|
|
91736
91943
|
if (markers.length === 0) {
|
|
91737
91944
|
return [{
|
|
91738
|
-
type:
|
|
91945
|
+
type: 'text',
|
|
91739
91946
|
value: text
|
|
91740
91947
|
}];
|
|
91741
91948
|
}
|
|
91742
91949
|
|
|
91743
|
-
// Merge markers that are separated only by whitespace into
|
|
91744
|
-
// cluster. `[1][2]`, `[1] [2]`, and `[1,2]` all read as one citation
|
|
91745
|
-
// cluster attached to the preceding sentence.
|
|
91950
|
+
// Merge markers that are separated only by whitespace into one cluster.
|
|
91746
91951
|
const clusters = [];
|
|
91747
|
-
for (
|
|
91952
|
+
for (let i = 0; i < markers.length; i += 1) {
|
|
91953
|
+
const cur = markers[i];
|
|
91748
91954
|
const last = clusters.length ? clusters[clusters.length - 1] : null;
|
|
91749
91955
|
if (last && /^\s*$/.test(text.slice(last.end, cur.start))) {
|
|
91750
91956
|
last.end = cur.end;
|
|
91751
|
-
last.indices.push(
|
|
91957
|
+
for (let j = 0; j < cur.indices.length; j += 1) last.indices.push(cur.indices[j]);
|
|
91752
91958
|
} else {
|
|
91753
91959
|
clusters.push({
|
|
91754
91960
|
start: cur.start,
|
|
91755
91961
|
end: cur.end,
|
|
91756
|
-
indices:
|
|
91962
|
+
indices: cur.indices.slice()
|
|
91757
91963
|
});
|
|
91758
91964
|
}
|
|
91759
91965
|
}
|
|
91760
91966
|
const out = [];
|
|
91761
91967
|
let cursor = 0;
|
|
91762
|
-
for (
|
|
91968
|
+
for (let i = 0; i < clusters.length; i += 1) {
|
|
91969
|
+
const c = clusters[i];
|
|
91763
91970
|
if (c.start > cursor) {
|
|
91764
91971
|
out.push({
|
|
91765
|
-
type:
|
|
91972
|
+
type: 'text',
|
|
91766
91973
|
value: text.slice(cursor, c.start)
|
|
91767
91974
|
});
|
|
91768
91975
|
}
|
|
91769
91976
|
out.push({
|
|
91770
|
-
type:
|
|
91977
|
+
type: 'cite',
|
|
91771
91978
|
indices: dedupPreserveOrder(c.indices)
|
|
91772
91979
|
});
|
|
91773
91980
|
cursor = c.end;
|
|
91774
91981
|
}
|
|
91775
91982
|
if (cursor < text.length) {
|
|
91776
91983
|
out.push({
|
|
91777
|
-
type:
|
|
91984
|
+
type: 'text',
|
|
91778
91985
|
value: text.slice(cursor)
|
|
91779
91986
|
});
|
|
91780
91987
|
}
|
|
91781
91988
|
return out;
|
|
91782
91989
|
}
|
|
91990
|
+
|
|
91991
|
+
/** `[1,3]` -> `"1,3"`, the wire format of the `data-eshal-citation` attribute. */
|
|
91783
91992
|
function serializeCitationIndices(indices) {
|
|
91784
|
-
return indices.join(
|
|
91993
|
+
return indices.join(',');
|
|
91785
91994
|
}
|
|
91995
|
+
|
|
91996
|
+
/** `"1,3"` -> `[1, 3]`; invalid and repeated entries dropped. */
|
|
91786
91997
|
function parseCitationIndicesFromAttr(attr) {
|
|
91787
91998
|
if (!attr) return [];
|
|
91788
91999
|
const out = [];
|
|
91789
|
-
|
|
91790
|
-
|
|
92000
|
+
const parts = String(attr).split(',');
|
|
92001
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
92002
|
+
const n = parseInt(parts[i].trim(), 10);
|
|
91791
92003
|
if (Number.isFinite(n) && n >= 1) out.push(n);
|
|
91792
92004
|
}
|
|
91793
92005
|
return dedupPreserveOrder(out);
|
|
91794
92006
|
}
|
|
92007
|
+
function isCodeParent(parent) {
|
|
92008
|
+
return parent.type === 'element' && (parent.tagName === 'code' || parent.tagName === 'pre');
|
|
92009
|
+
}
|
|
92010
|
+
|
|
92011
|
+
// Replace the text node at `parent.children[index]` with its citation
|
|
92012
|
+
// segments. Returns the index to continue from.
|
|
92013
|
+
function replaceCitationText(node, index, parent) {
|
|
92014
|
+
// Text inside <code> / <pre> is not a citation (someone showing example
|
|
92015
|
+
// markdown).
|
|
92016
|
+
if (isCodeParent(parent)) return index + 1;
|
|
92017
|
+
const segments = parseCitationClusters(node.value);
|
|
92018
|
+
// No markers found - leave the text node untouched.
|
|
92019
|
+
if (segments.length === 1 && segments[0].type === 'text') return index + 1;
|
|
92020
|
+
|
|
92021
|
+
// 2026-07-14 - glue the pill to its preceding word: a regular space right
|
|
92022
|
+
// before a cite segment becomes a non-breaking space, so the pill never
|
|
92023
|
+
// wraps alone onto a new line.
|
|
92024
|
+
const replacements = segments.map(function (seg, i) {
|
|
92025
|
+
if (seg.type === 'text') {
|
|
92026
|
+
const nextIsCite = i + 1 < segments.length && segments[i + 1].type === 'cite';
|
|
92027
|
+
const value = nextIsCite ? seg.value.replace(/[ \t]+$/, ' ') : seg.value;
|
|
92028
|
+
return {
|
|
92029
|
+
type: 'text',
|
|
92030
|
+
value: value
|
|
92031
|
+
};
|
|
92032
|
+
}
|
|
92033
|
+
return {
|
|
92034
|
+
type: 'element',
|
|
92035
|
+
tagName: 'span',
|
|
92036
|
+
properties: {
|
|
92037
|
+
dataEshalCitation: serializeCitationIndices(seg.indices)
|
|
92038
|
+
},
|
|
92039
|
+
children: []
|
|
92040
|
+
};
|
|
92041
|
+
});
|
|
92042
|
+
parent.children.splice.apply(parent.children, [index, 1].concat(replacements));
|
|
92043
|
+
return index + replacements.length;
|
|
92044
|
+
}
|
|
92045
|
+
function walkCitationText(parent) {
|
|
92046
|
+
const children = parent.children;
|
|
92047
|
+
if (!Array.isArray(children)) return;
|
|
92048
|
+
let i = 0;
|
|
92049
|
+
while (i < children.length) {
|
|
92050
|
+
const node = children[i];
|
|
92051
|
+
if (node && node.type === 'text') {
|
|
92052
|
+
i = replaceCitationText(node, i, parent);
|
|
92053
|
+
continue;
|
|
92054
|
+
}
|
|
92055
|
+
if (node && Array.isArray(node.children)) walkCitationText(node);
|
|
92056
|
+
i += 1;
|
|
92057
|
+
}
|
|
92058
|
+
}
|
|
91795
92059
|
|
|
91796
92060
|
/**
|
|
91797
|
-
* rehype plugin
|
|
91798
|
-
*
|
|
91799
|
-
*
|
|
91800
|
-
* each placeholder for a `<CitationPill>` React node at render time.
|
|
92061
|
+
* rehype plugin: replaces `[N]` citation markers in HAST text nodes with
|
|
92062
|
+
* `<span data-eshal-citation="1,3">` placeholders, which each surface's
|
|
92063
|
+
* `components.span` override swaps for its CitationPill.
|
|
91801
92064
|
*
|
|
91802
|
-
* Why a HAST plugin and not a
|
|
91803
|
-
*
|
|
91804
|
-
*
|
|
91805
|
-
* replacement only touches text nodes, so `**bold [1]**` stays one
|
|
91806
|
-
* bold span with the pill nested at the right place.
|
|
92065
|
+
* Why a HAST plugin and not a string transform: Markdown spans (bold, links)
|
|
92066
|
+
* can wrap the marker - HAST-time replacement only touches text nodes, so
|
|
92067
|
+
* `**bold [1]**` stays one bold span with the pill nested inside it.
|
|
91807
92068
|
*
|
|
91808
|
-
*
|
|
91809
|
-
*
|
|
92069
|
+
* Dependency-free tree walk (no unist-util-visit) so the shared file has no
|
|
92070
|
+
* imports. Same order and the same skip-past-the-replacements rule.
|
|
91810
92071
|
*/
|
|
91811
92072
|
function rehypeCitations() {
|
|
91812
|
-
return tree
|
|
91813
|
-
|
|
91814
|
-
|
|
91815
|
-
|
|
91816
|
-
|
|
91817
|
-
|
|
91818
|
-
|
|
92073
|
+
return function (tree) {
|
|
92074
|
+
if (tree) walkCitationText(tree);
|
|
92075
|
+
};
|
|
92076
|
+
}
|
|
92077
|
+
|
|
92078
|
+
// ── CitationPill source resolution ─────────────────────────────────────
|
|
92079
|
+
|
|
92080
|
+
function firstDefined(src, fields) {
|
|
92081
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
92082
|
+
const v = src[fields[i]];
|
|
92083
|
+
if (v !== null && v !== undefined) return v;
|
|
92084
|
+
}
|
|
92085
|
+
return undefined;
|
|
92086
|
+
}
|
|
92087
|
+
function firstTruthy(src, fields) {
|
|
92088
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
92089
|
+
const v = src[fields[i]];
|
|
92090
|
+
if (v) return v;
|
|
92091
|
+
}
|
|
92092
|
+
return undefined;
|
|
92093
|
+
}
|
|
92094
|
+
|
|
92095
|
+
/** Percent-decoded source name; the raw value when it is not valid encoding. */
|
|
92096
|
+
function decodeSourceName(name) {
|
|
92097
|
+
if (!name) return '';
|
|
92098
|
+
try {
|
|
92099
|
+
return decodeURIComponent(String(name));
|
|
92100
|
+
} catch (e) {
|
|
92101
|
+
return String(name);
|
|
92102
|
+
}
|
|
92103
|
+
}
|
|
92104
|
+
|
|
92105
|
+
/** What a pill or popover row shows for a source. */
|
|
92106
|
+
function citationDisplayLabel(src, fallbackIndex) {
|
|
92107
|
+
return decodeSourceName(src.source_name) || src.url || src.source_type || 'Source ' + fallbackIndex;
|
|
92108
|
+
}
|
|
92109
|
+
|
|
92110
|
+
/**
|
|
92111
|
+
* Is this source a file (as opposed to a web page)? `idFields` are the id
|
|
92112
|
+
* fields the surface reads: a source with an id and no url is a file.
|
|
92113
|
+
*/
|
|
92114
|
+
function isFileLikeSource$1(src, idFields) {
|
|
92115
|
+
if (!src) return false;
|
|
92116
|
+
const url = src.url;
|
|
92117
|
+
return src.source_type === 'file' || typeof url === 'string' && url.indexOf('/api/v1/sharepoint/file/') !== -1 || !!firstTruthy(src, idFields) && !url;
|
|
92118
|
+
}
|
|
92119
|
+
|
|
92120
|
+
/** The key a surface's download state (`downloadingKey`) is matched on. */
|
|
92121
|
+
function citationSourceKey(src, idFields) {
|
|
92122
|
+
return src.url || firstTruthy(src, idFields) || src.source_name || '';
|
|
92123
|
+
}
|
|
92124
|
+
|
|
92125
|
+
/**
|
|
92126
|
+
* Resolve a cluster's 1-based indices to source cards.
|
|
92127
|
+
*
|
|
92128
|
+
* Prefers each source's backend-emitted `citation_indices` (keeps the LLM's
|
|
92129
|
+
* original chunk position through filter, dedup and ACL), falling back to the
|
|
92130
|
+
* array position for older backends. Out-of-range indices are skipped so a
|
|
92131
|
+
* hallucinated `[99]` cannot blank the pill. Sources are de-duplicated by
|
|
92132
|
+
* identity - url first (unique per web page), then the id fields, then the
|
|
92133
|
+
* name - keeping the first index as the display anchor.
|
|
92134
|
+
*/
|
|
92135
|
+
function resolveCitedSources(sources, indices, idFields) {
|
|
92136
|
+
const out = [];
|
|
92137
|
+
if (!Array.isArray(sources)) return out;
|
|
92138
|
+
const keyFields = ['url'].concat(idFields, ['source_name']);
|
|
92139
|
+
const seen = new Set();
|
|
92140
|
+
for (let i = 0; i < indices.length; i += 1) {
|
|
92141
|
+
const n = indices[i];
|
|
92142
|
+
let src;
|
|
92143
|
+
for (let j = 0; j < sources.length; j += 1) {
|
|
92144
|
+
const s = sources[j];
|
|
92145
|
+
if (s && Array.isArray(s.citation_indices) && s.citation_indices.indexOf(n) !== -1) {
|
|
92146
|
+
src = s;
|
|
92147
|
+
break;
|
|
91819
92148
|
}
|
|
91820
|
-
|
|
91821
|
-
|
|
91822
|
-
|
|
91823
|
-
|
|
91824
|
-
|
|
91825
|
-
|
|
91826
|
-
|
|
91827
|
-
|
|
91828
|
-
|
|
91829
|
-
|
|
91830
|
-
const value = nextIsCite ? seg.value.replace(/[ \t]+$/, "\u00a0") : seg.value;
|
|
91831
|
-
return {
|
|
91832
|
-
type: "text",
|
|
91833
|
-
value
|
|
91834
|
-
};
|
|
91835
|
-
}
|
|
91836
|
-
return {
|
|
91837
|
-
type: "element",
|
|
91838
|
-
tagName: "span",
|
|
91839
|
-
properties: {
|
|
91840
|
-
dataEshalCitation: serializeCitationIndices(seg.indices)
|
|
91841
|
-
},
|
|
91842
|
-
children: []
|
|
91843
|
-
};
|
|
91844
|
-
});
|
|
91845
|
-
parent.children.splice(index, 1, ...replacements);
|
|
91846
|
-
return [SKIP, index + replacements.length];
|
|
92149
|
+
}
|
|
92150
|
+
if (src === undefined) src = sources[n - 1];
|
|
92151
|
+
if (!src) continue;
|
|
92152
|
+
const raw = firstDefined(src, keyFields);
|
|
92153
|
+
const key = String(raw === undefined ? '' : raw).toLowerCase();
|
|
92154
|
+
if (key && seen.has(key)) continue;
|
|
92155
|
+
if (key) seen.add(key);
|
|
92156
|
+
out.push({
|
|
92157
|
+
src: src,
|
|
92158
|
+
index: n
|
|
91847
92159
|
});
|
|
91848
|
-
}
|
|
92160
|
+
}
|
|
92161
|
+
return out;
|
|
91849
92162
|
}
|
|
91850
92163
|
|
|
91851
92164
|
/**
|
|
@@ -91952,25 +92265,17 @@
|
|
|
91952
92265
|
return Boolean(source.source_id);
|
|
91953
92266
|
}
|
|
91954
92267
|
|
|
91955
|
-
|
|
91956
|
-
|
|
91957
|
-
|
|
91958
|
-
|
|
91959
|
-
|
|
91960
|
-
|
|
91961
|
-
}
|
|
91962
|
-
}
|
|
92268
|
+
// Label, file and key rules and the cluster -> source resolution are the
|
|
92269
|
+
// shared chat core's (packages/shared/src/chatCore/citations.js, EV-4798
|
|
92270
|
+
// MERN-19), shared with the SDK and frontend pills. The widget reads
|
|
92271
|
+
// `source_id` for "is this a file" and both id spellings for identity.
|
|
92272
|
+
const FILE_ID_FIELDS = ["source_id"];
|
|
92273
|
+
const KEY_ID_FIELDS = ["source_id", "sourceid"];
|
|
91963
92274
|
function isFileLikeSource(s) {
|
|
91964
|
-
|
|
91965
|
-
const src_type = s.source_type;
|
|
91966
|
-
const url = s.url;
|
|
91967
|
-
return src_type === "file" || typeof url === "string" && url.includes("/api/v1/sharepoint/file/") || !!s.source_id && !url;
|
|
91968
|
-
}
|
|
91969
|
-
function displayLabel(s, fallbackIndex) {
|
|
91970
|
-
return decodeName(s.source_name) || s.url || s.source_type || `Source ${fallbackIndex}`;
|
|
92275
|
+
return isFileLikeSource$1(s, FILE_ID_FIELDS);
|
|
91971
92276
|
}
|
|
91972
92277
|
function pillKey(s) {
|
|
91973
|
-
return s
|
|
92278
|
+
return citationSourceKey(s, KEY_ID_FIELDS);
|
|
91974
92279
|
}
|
|
91975
92280
|
function CitationPill({
|
|
91976
92281
|
indices
|
|
@@ -92002,26 +92307,7 @@
|
|
|
92002
92307
|
// FIRST index encountered as the display anchor.
|
|
92003
92308
|
const referenced = React.useMemo(() => {
|
|
92004
92309
|
if (!ctx || !Array.isArray(ctx.sources)) return [];
|
|
92005
|
-
|
|
92006
|
-
const seen = new Set();
|
|
92007
|
-
for (const n of indices) {
|
|
92008
|
-
const byCitationIndex = ctx.sources.find(s => Array.isArray(s?.citation_indices) && s.citation_indices.includes(n));
|
|
92009
|
-
const src = byCitationIndex ?? ctx.sources[n - 1];
|
|
92010
|
-
if (!src) continue;
|
|
92011
|
-
// 2026-07-15 - prioritise URL (unique per web page) over
|
|
92012
|
-
// sourceid (shared across every page of the same Knowledge row).
|
|
92013
|
-
// Website-connector sources emit distinct URLs for distinct
|
|
92014
|
-
// pages but a single Knowledge row id - dedupping on sourceid
|
|
92015
|
-
// first collapsed them to one row in the popover.
|
|
92016
|
-
const key = String(src.url ?? src.source_id ?? src.sourceid ?? src.source_name ?? "").toLowerCase();
|
|
92017
|
-
if (key && seen.has(key)) continue;
|
|
92018
|
-
if (key) seen.add(key);
|
|
92019
|
-
out.push({
|
|
92020
|
-
src,
|
|
92021
|
-
index: n
|
|
92022
|
-
});
|
|
92023
|
-
}
|
|
92024
|
-
return out;
|
|
92310
|
+
return resolveCitedSources(ctx.sources, indices, KEY_ID_FIELDS);
|
|
92025
92311
|
}, [ctx, indices]);
|
|
92026
92312
|
|
|
92027
92313
|
// Close on Esc / outside click.
|
|
@@ -92141,7 +92427,7 @@
|
|
|
92141
92427
|
}
|
|
92142
92428
|
const first = referenced[0];
|
|
92143
92429
|
const extraCount = referenced.length - 1;
|
|
92144
|
-
const firstLabel =
|
|
92430
|
+
const firstLabel = citationDisplayLabel(first.src, first.index);
|
|
92145
92431
|
// EV-4177: a card only gets the download affordance when a click will
|
|
92146
92432
|
// actually produce a file. isFileLikeSource alone answers "is this a file",
|
|
92147
92433
|
// not "can we fetch it" - which is how a download icon ended up on a source
|
|
@@ -92163,14 +92449,22 @@
|
|
|
92163
92449
|
if (open) setOpen(false);else openPopover();
|
|
92164
92450
|
};
|
|
92165
92451
|
const iconSize = 12;
|
|
92452
|
+
|
|
92453
|
+
// EV-4798 (MERN-21): accessible names in the widget locale. `ctx.t` is
|
|
92454
|
+
// optional (a provider from an older bundle may not pass it), so each
|
|
92455
|
+
// string keeps its English form as the fallback.
|
|
92456
|
+
const tr = (key, english) => typeof ctx.t === "function" ? ctx.t(key) : english;
|
|
92457
|
+
const pillName = firstLabel + (extraCount ? ` (${tr("moreSources", "+{count} more").replace("{count}", String(extraCount))})` : "");
|
|
92166
92458
|
return /*#__PURE__*/jsxRuntimeExports.jsxs(jsxRuntimeExports.Fragment, {
|
|
92167
92459
|
children: [/*#__PURE__*/jsxRuntimeExports.jsxs("span", {
|
|
92168
92460
|
ref: anchorRef,
|
|
92169
92461
|
role: "button",
|
|
92170
92462
|
tabIndex: 0,
|
|
92171
92463
|
dir: "ltr",
|
|
92172
|
-
title:
|
|
92173
|
-
"aria-label":
|
|
92464
|
+
title: pillName,
|
|
92465
|
+
"aria-label": pillName,
|
|
92466
|
+
"aria-haspopup": referenced.length > 1 ? "dialog" : undefined,
|
|
92467
|
+
"aria-expanded": referenced.length > 1 ? open : undefined,
|
|
92174
92468
|
onClick: onPillClick,
|
|
92175
92469
|
onKeyDown: e => {
|
|
92176
92470
|
if (e.key === "Enter" || e.key === " ") {
|
|
@@ -92265,11 +92559,18 @@
|
|
|
92265
92559
|
strokeWidth: 2
|
|
92266
92560
|
})
|
|
92267
92561
|
})]
|
|
92268
|
-
}), open && pos && typeof document !== "undefined" && /*#__PURE__*/reactDomExports.createPortal(
|
|
92562
|
+
}), open && pos && typeof document !== "undefined" && /*#__PURE__*/reactDomExports.createPortal(
|
|
92563
|
+
/*#__PURE__*/
|
|
92564
|
+
// The click/mousedown handlers below only stop propagation so the
|
|
92565
|
+
// bubble's own handlers do not see clicks inside the popover; the
|
|
92566
|
+
// dialog is not itself a click target, and Escape plus the Tab trap
|
|
92567
|
+
// are handled by the effect above.
|
|
92568
|
+
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events
|
|
92569
|
+
jsxRuntimeExports.jsxs("div", {
|
|
92269
92570
|
ref: popoverRef,
|
|
92270
92571
|
role: "dialog",
|
|
92271
92572
|
dir: "ltr",
|
|
92272
|
-
"aria-label":
|
|
92573
|
+
"aria-label": tr("sourcesCited", "{count} sources cited").replace("{count}", String(referenced.length)),
|
|
92273
92574
|
style: {
|
|
92274
92575
|
position: "absolute",
|
|
92275
92576
|
top: pos.top,
|
|
@@ -92304,7 +92605,7 @@
|
|
|
92304
92605
|
src,
|
|
92305
92606
|
index
|
|
92306
92607
|
}) => {
|
|
92307
|
-
const label =
|
|
92608
|
+
const label = citationDisplayLabel(src, index);
|
|
92308
92609
|
// EV-4177: same gate as firstIsFile above.
|
|
92309
92610
|
const rowIsFile = isFileLikeSource(src) && canDownloadSource(src);
|
|
92310
92611
|
const kBusy = ctx.downloadingKey && ctx.downloadingKey === pillKey(src);
|