@kodaris/krubble-app-components 1.0.106 → 1.0.108

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.
@@ -5778,10 +5778,10 @@
5778
5778
  */
5779
5779
  this._globalSkills = [];
5780
5780
  /**
5781
- * Latest results for the `/` composer menu: this instance's own org skills PLUS
5782
- * the global skills installed on it (both are invocable by code). A separate
5783
- * buffer from the two browse-tab lists so merging installed globals into the
5784
- * menu never leaks them into the "Your Org" tab.
5781
+ * Latest results for the `/` composer menu — everything the current user can
5782
+ * actually invoke, merged and role-filtered server-side by
5783
+ * `/conversation/availableSkills`. A separate buffer from the two browse-tab lists
5784
+ * so installed globals in the menu never leak into the "Your Org" tab.
5785
5785
  */
5786
5786
  this._invocableSkills = [];
5787
5787
  /**
@@ -6359,77 +6359,30 @@
6359
6359
  }
6360
6360
  // --- Skills ---
6361
6361
  /**
6362
- * Search the Kodaris AI skills index and store the results in `_orgSkills`.
6363
- * The `/` menu and the browse dialog both call this; results are shared since
6364
- * the two are never open at once. Mirrors the backend's own skill search (BM25
6365
- * over name^3/description^2/instructions, active-only) so the menu ranks skills
6366
- * the way the model does. An empty query uses match_all — multi_match with an
6367
- * empty string matches nothing, so the "show everything" case needs it.
6362
+ * Load the browse dialog's "Your Org" tab into `_orgSkills` from
6363
+ * `/conversation/localSkillList` — this instance's own active skills, already
6364
+ * role-filtered to what the caller may run and ranked by the server.
6368
6365
  *
6369
- * Follows the kr-combo-box `_fetch` pattern: a request-id guard drops responses
6370
- * that arrive out of order. `/aiskills/search` returns a raw OpenSearch response,
6371
- * so we lift each hit's `_source` (already in KRChatbotSkill shape) out.
6366
+ * Not the `aiskills` search endpoint: that requires a skill-authoring role
6367
+ * (`skillView`/`skillEdit`), so an ordinary chat user got a 403 and an empty
6368
+ * tab. This one only needs a logged-in employee. It also means the query
6369
+ * matching, active-only filter and "exclude globally-published" rule all live
6370
+ * server-side instead of being rebuilt as OpenSearch DSL here.
6371
+ *
6372
+ * EXCEPTION — on kodaris.com this list also carries that instance's own
6373
+ * globally-published skills (flagged `global: true`), because there is no
6374
+ * Kodaris tab there for them to appear on.
6375
+ *
6376
+ * Request-id guard drops responses that arrive out of order (kr-combo-box
6377
+ * `_fetch` pattern).
6372
6378
  */
6373
6379
  _searchOrgSkills(query) {
6374
6380
  this._orgSkillRequestId++;
6375
6381
  const requestId = this._orgSkillRequestId;
6376
- const trimmed = query.trim();
6377
- // An empty query matches everything; otherwise substring-match the query
6378
- // against name/code/description. We use case-insensitive wildcard queries on
6379
- // the `.raw` keyword subfields so "ord" matches anywhere in "Create Order"
6380
- // (a plain match/multi_match only matches whole tokens). Escape the wildcard
6381
- // specials so user input is treated literally, then wrap with `*...*`.
6382
- let must = { match_all: {} };
6383
- if (trimmed) {
6384
- must = {
6385
- bool: {
6386
- should: [
6387
- {
6388
- wildcard: {
6389
- 'name.raw': {
6390
- value: `*${trimmed.replace(/([*?\\])/g, '\\$1')}*`,
6391
- case_insensitive: true,
6392
- }
6393
- }
6394
- },
6395
- {
6396
- wildcard: {
6397
- 'code.raw': {
6398
- value: `*${trimmed.replace(/([*?\\])/g, '\\$1')}*`,
6399
- case_insensitive: true,
6400
- }
6401
- }
6402
- },
6403
- {
6404
- wildcard: {
6405
- 'description.raw': {
6406
- value: `*${trimmed.replace(/([*?\\])/g, '\\$1')}*`,
6407
- case_insensitive: true,
6408
- }
6409
- }
6410
- },
6411
- ],
6412
- minimum_should_match: 1,
6413
- },
6414
- };
6415
- }
6416
6382
  krubbleHttp.Http.fetch({
6417
- url: '/api/system/opensearch/aiskills/search',
6383
+ url: '/api/system/conversation/localSkillList',
6418
6384
  method: 'POST',
6419
- body: {
6420
- size: 100,
6421
- query: {
6422
- bool: {
6423
- must: [must],
6424
- filter: [{ term: { active: true } }],
6425
- // The "Your Org" tab lists only this instance's own skills. Globally-
6426
- // published skills belong to the "Kodaris" tab (fed by the registry),
6427
- // so exclude them here. Documents without the field aren't matched by
6428
- // the term, so ordinary org skills are kept.
6429
- must_not: [{ term: { globalPublished: true } }],
6430
- },
6431
- },
6432
- },
6385
+ body: { query: query.trim() },
6433
6386
  })
6434
6387
  .then((r) => r.json())
6435
6388
  .then((res) => {
@@ -6437,114 +6390,63 @@
6437
6390
  if (requestId !== this._orgSkillRequestId) {
6438
6391
  return;
6439
6392
  }
6440
- this._orgSkills = (res?.data?.hits?.hits || []).map((hit) => hit._source);
6393
+ this._orgSkills = (res?.data || []);
6441
6394
  })
6442
6395
  .catch((error) => {
6443
6396
  console.error('kr-chatbot: skill search failed', error);
6444
6397
  });
6445
6398
  }
6446
6399
  /**
6447
- * Search skills for the `/` composer menu into `_invocableSkills`: this instance's
6448
- * own org skills PLUS the global skills installed on it, since both are
6449
- * invocable by code. They come from two indexes (the local aiskills search and
6450
- * the registry search filtered client-side to enabled), so we fire both and
6451
- * concat — org skills first, then installed globals. Skill codes are unique
6452
- * across the two, so no dedup is needed. Kept out of `_orgSkills` so
6453
- * installed globals never leak into the browse dialog's "Your Org" tab.
6454
- * (Cross-list relevance ranking is a later concern; for now org skills lead.)
6400
+ * Load the skills for the `/` composer menu into `_invocableSkills` from
6401
+ * `/conversation/availableSkills` — this instance's own skills plus the installed
6402
+ * global ones, already merged, role-filtered and ranked by the server. That's the
6403
+ * same set the model sees via `list_skills`, so the menu can't offer a skill the
6404
+ * chat would then refuse.
6405
+ *
6406
+ * Not the `aiskills` endpoints the browse dialog's Org tab uses: those require a
6407
+ * skill-authoring role, which an ordinary chat user doesn't have.
6408
+ *
6409
+ * Request-id guard drops responses that arrive out of order (kr-combo-box `_fetch`
6410
+ * pattern).
6455
6411
  */
6456
6412
  _searchInvocableSkills(query) {
6457
6413
  this._invocableSkillRequestId++;
6458
6414
  const requestId = this._invocableSkillRequestId;
6459
- // Same wildcard matching as _searchOrgSkills (see there for the rationale).
6460
- let must = { match_all: {} };
6461
- if (query.trim()) {
6462
- must = {
6463
- bool: {
6464
- should: [
6465
- {
6466
- wildcard: {
6467
- 'name.raw': {
6468
- value: `*${query.trim().replace(/([*?\\])/g, '\\$1')}*`,
6469
- case_insensitive: true,
6470
- }
6471
- }
6472
- },
6473
- {
6474
- wildcard: {
6475
- 'code.raw': {
6476
- value: `*${query.trim().replace(/([*?\\])/g, '\\$1')}*`,
6477
- case_insensitive: true,
6478
- }
6479
- }
6480
- },
6481
- {
6482
- wildcard: {
6483
- 'description.raw': {
6484
- value: `*${query.trim().replace(/([*?\\])/g, '\\$1')}*`,
6485
- case_insensitive: true,
6486
- }
6487
- }
6488
- },
6489
- ],
6490
- minimum_should_match: 1,
6491
- },
6492
- };
6493
- }
6494
- // Org skills — this instance's own catalog (globalPublished excluded).
6495
- const orgSearch = krubbleHttp.Http.fetch({
6496
- url: '/api/system/opensearch/aiskills/search',
6497
- method: 'POST',
6498
- body: {
6499
- size: 100,
6500
- query: {
6501
- bool: {
6502
- must: [must],
6503
- filter: [{ term: { active: true } }],
6504
- must_not: [{ term: { globalPublished: true } }],
6505
- },
6506
- },
6507
- },
6508
- })
6509
- .then((r) => r.json())
6510
- .then((res) => (res?.data?.hits?.hits || []).map((hit) => hit._source))
6511
- .catch((error) => {
6512
- console.error('kr-chatbot: invocable skill search failed', error);
6513
- return [];
6514
- });
6515
- // Installed globals — the registry catalog, kept only where enabled=true.
6516
- const globalSearch = krubbleHttp.Http.fetch({
6517
- url: '/api/system/opensearch/aiskills/global/search',
6415
+ krubbleHttp.Http.fetch({
6416
+ url: '/api/system/conversation/availableSkills',
6518
6417
  method: 'POST',
6519
6418
  body: { query: query.trim() },
6520
6419
  })
6521
6420
  .then((r) => r.json())
6522
- .then((res) => (res?.data || []).filter((s) => !!s.enabled))
6523
- .catch((error) => {
6524
- console.error('kr-chatbot: installed global skill search failed', error);
6525
- return [];
6526
- });
6527
- Promise.all([orgSearch, globalSearch]).then(([orgSkills, globalSkills]) => {
6421
+ .then((res) => {
6528
6422
  // Ignore a stale response superseded by a newer search.
6529
6423
  if (requestId !== this._invocableSkillRequestId) {
6530
6424
  return;
6531
6425
  }
6532
- this._invocableSkills = [...orgSkills, ...globalSkills];
6426
+ this._invocableSkills = (res?.data || []);
6427
+ })
6428
+ .catch((error) => {
6429
+ console.error('kr-chatbot: invocable skill search failed', error);
6533
6430
  });
6534
6431
  }
6535
6432
  /**
6536
- * Search the shared Kodaris global-skill registry into `_globalSkills`
6537
- * (the browse dialog's "Kodaris" tab). Unlike `/aiskills/search`,
6538
- * `/aiskills/global/search` returns a digested list
6539
- * (`data: [{ code, name, description, enabled }]`) already in `KRChatbotSkill`
6540
- * shape, so no `_source` lifting is needed. Only the browse dialog calls this
6541
- * (the `/` menu is org-only), so it isn't run per slash keystroke.
6433
+ * Load the browse dialog's "Kodaris" tab into `_globalSkills` — the whole
6434
+ * published Kodaris catalog for discovery, installed or not, runnable by this
6435
+ * user or not. Each row carries `enabled` (installed here), `canEnable` (may
6436
+ * install it) and `canRun` (roles allow invoking it).
6437
+ *
6438
+ * Uses `/conversation/globalSkillList` rather than the `/aiskills/global/search`
6439
+ * twin, which requires a skill-authoring role an ordinary chat user doesn't have.
6440
+ *
6441
+ * Returns `[]` — not an error — when there's no reachable registry, and always
6442
+ * on kodaris.com, which IS the catalog source and so has no remote to pull. The
6443
+ * tab itself is hidden there (see `_renderSkillBrowse`).
6542
6444
  */
6543
6445
  _searchGlobalSkills(query) {
6544
6446
  this._globalSkillRequestId++;
6545
6447
  const requestId = this._globalSkillRequestId;
6546
6448
  krubbleHttp.Http.fetch({
6547
- url: '/api/system/opensearch/aiskills/global/search',
6449
+ url: '/api/system/conversation/globalSkillList',
6548
6450
  method: 'POST',
6549
6451
  body: { query: query.trim() },
6550
6452
  })
@@ -6568,6 +6470,17 @@
6568
6470
  _handleGlobalSkillEnable(skill, e) {
6569
6471
  // The card body inserts the skill; the install button must not also do that.
6570
6472
  e.stopPropagation();
6473
+ // The button is shown to everyone so the catalog reads the same for all users,
6474
+ // but the enable endpoint requires administrator/superuser/skillEdit. Without
6475
+ // it, say who to ask instead of firing a request that comes back 403.
6476
+ if (!skill.canEnable) {
6477
+ krubbleComponents.Snackbar.show({
6478
+ title: 'Permission Required',
6479
+ message: 'You do not have permission to install skills. Please contact your site administrator.',
6480
+ type: 'error',
6481
+ });
6482
+ return;
6483
+ }
6571
6484
  if (this._globalSkillPending.has(skill.code)) {
6572
6485
  return;
6573
6486
  }
@@ -6694,13 +6607,33 @@
6694
6607
  recognition.start();
6695
6608
  this._inputEl?.focus();
6696
6609
  }
6610
+ /**
6697
6611
  /** Insert `/code ` into the composer and focus it, ready for the message. */
6698
6612
  _handleSkillSelect(skill) {
6699
- // A global skill must be installed before it can be chosen — inserting /code
6700
- // for one not enabled on this instance would just fail. `enabled` is only set
6701
- // on global skills (false = not installed); org skills leave it undefined and
6702
- // are always selectable. So an uninstalled global card click does nothing.
6703
- if (skill.enabled === false) {
6613
+ // The user's roles don't allow this skill — inserting /code would just fail in the
6614
+ // chat. Name the roles that would grant it, so the user knows what to ask for and
6615
+ // whoever grants it knows what to give.
6616
+ if (skill.canRun === false) {
6617
+ let message = `You do not have access to "${skill.name}". Ask your site administrator to grant it.`;
6618
+ if (skill.allowedRoles?.length) {
6619
+ message = `"${skill.name}" requires the ${skill.allowedRoles.join(' or ')} role. Ask your site administrator to grant it.`;
6620
+ }
6621
+ krubbleComponents.Snackbar.show({
6622
+ title: 'Access Required',
6623
+ message: message,
6624
+ type: 'error',
6625
+ });
6626
+ return;
6627
+ }
6628
+ // A global skill nobody has installed here yet isn't runnable either — point at the
6629
+ // Install button rather than appearing to ignore the click. Local skills carry no
6630
+ // install state, so this only applies to global ones.
6631
+ if (skill.global && !skill.enabled) {
6632
+ krubbleComponents.Snackbar.show({
6633
+ title: 'Not Installed',
6634
+ message: `"${skill.name}" has to be installed on this site before you can use it — use the Install button on the card.`,
6635
+ type: 'info',
6636
+ });
6704
6637
  return;
6705
6638
  }
6706
6639
  this._inputValue = `/${skill.code} `;
@@ -6732,8 +6665,11 @@
6732
6665
  this._skillBrowseQuery = '';
6733
6666
  this._skillBrowseOpened = true;
6734
6667
  // Fill both tabs up front so switching between them is instant, not stale.
6668
+ // The Kodaris tab isn't rendered on the registry server, so skip its fetch there.
6735
6669
  this._searchOrgSkills('');
6736
- this._searchGlobalSkills('');
6670
+ if (this._aiFrameworkConfig.registryServer !== true) {
6671
+ this._searchGlobalSkills('');
6672
+ }
6737
6673
  }
6738
6674
  _handleSkillBrowseClose() {
6739
6675
  this._skillBrowseOpened = false;
@@ -6742,7 +6678,9 @@
6742
6678
  this._skillBrowseQuery = e.target.value;
6743
6679
  // Filter both catalogs so whichever tab the user switches to is already current.
6744
6680
  this._searchOrgSkills(this._skillBrowseQuery);
6745
- this._searchGlobalSkills(this._skillBrowseQuery);
6681
+ if (this._aiFrameworkConfig.registryServer !== true) {
6682
+ this._searchGlobalSkills(this._skillBrowseQuery);
6683
+ }
6746
6684
  }
6747
6685
  _handleSkillBrowseTabChange(e) {
6748
6686
  // Both buffers are kept current by open/search, so switching just swaps which
@@ -7084,6 +7022,15 @@
7084
7022
  userText: params.message,
7085
7023
  fileIds: files.map((f) => f.conversationFileID),
7086
7024
  };
7025
+ // Ground the model in where the user is and what they've highlighted.
7026
+ // Only sent when present so an empty selection / failed capture doesn't
7027
+ // add noise to the request.
7028
+ if (params.pageContext) {
7029
+ body.pageContext = params.pageContext;
7030
+ }
7031
+ if (params.selectionContext) {
7032
+ body.selectionContext = params.selectionContext;
7033
+ }
7087
7034
  if (this.conversationId != null) {
7088
7035
  body.conversationID = this.conversationId;
7089
7036
  }
@@ -7128,10 +7075,16 @@
7128
7075
  });
7129
7076
  };
7130
7077
  }
7078
+ // Capture where the user is and what they've highlighted at send time (not
7079
+ // earlier — selection is transient and the route may have changed since the
7080
+ // panel opened), so every turn grounds the model in the user's current spot.
7081
+ const context = this._captureContext();
7131
7082
  this.send({
7132
7083
  message: params.message,
7133
7084
  messages: this.messages.slice(0, -1),
7134
7085
  conversationFiles: params.conversationFiles,
7086
+ pageContext: context.pageContext,
7087
+ selectionContext: context.selectionContext,
7135
7088
  }).then((response) => {
7136
7089
  if (!response.ok) {
7137
7090
  this._handleStreamError('Request failed: ' + response.status);
@@ -7151,6 +7104,56 @@
7151
7104
  }
7152
7105
  });
7153
7106
  }
7107
+ /**
7108
+ * Capture ambient "where is the user / what are they looking at" context at
7109
+ * send time, so the AI is grounded without the host passing anything in.
7110
+ *
7111
+ * Two universal sources that exist on every page the chatbot mounts in:
7112
+ * - `pageContext` — the location (full url, which already carries the path and
7113
+ * query) and title, plus, when the page renders the server-side
7114
+ * `#kodaris-page` JSON island (storefront content pages do), its parsed
7115
+ * contents (page name/type/ids). A page without the island still yields url
7116
+ * + title, so context is never empty on a real page.
7117
+ * - `selectionContext` — the text the user currently has highlighted, read
7118
+ * live here because a selection is transient (it may be gone a moment later).
7119
+ *
7120
+ * Both are returned as strings because that's what the backend `ModelForText`
7121
+ * fields (`pageContext` / `selectionContext`) accept. Capture is best-effort:
7122
+ * any failure yields an empty string for that field rather than blocking send.
7123
+ */
7124
+ _captureContext() {
7125
+ let pageContext = '';
7126
+ try {
7127
+ const page = {
7128
+ url: window.location.href,
7129
+ title: document.title,
7130
+ };
7131
+ // Structured page context is opt-in by convention: a page that renders the
7132
+ // `#kodaris-page` island gets its name/type/ids folded in for free. The
7133
+ // island's text is JSON; a malformed/absent island just leaves it out.
7134
+ const island = document.getElementById('kodaris-page');
7135
+ if (island && island.textContent) {
7136
+ try {
7137
+ page.page = JSON.parse(island.textContent);
7138
+ }
7139
+ catch { /* island present but not valid JSON — location context still stands */ }
7140
+ }
7141
+ pageContext = JSON.stringify(page);
7142
+ }
7143
+ catch { /* leave pageContext empty — never block a send on context capture */ }
7144
+ let selectionContext = '';
7145
+ try {
7146
+ const selection = window.getSelection();
7147
+ if (selection) {
7148
+ selectionContext = selection.toString().trim();
7149
+ }
7150
+ }
7151
+ catch { /* leave selectionContext empty */ }
7152
+ return {
7153
+ pageContext,
7154
+ selectionContext,
7155
+ };
7156
+ }
7154
7157
  _handleStop() {
7155
7158
  if (this._reader) {
7156
7159
  this._reader.cancel();
@@ -7424,7 +7427,14 @@
7424
7427
  </div>
7425
7428
  `;
7426
7429
  }
7427
- /** The "Browse all skills" dialog — a searchable grid of Org / Kodaris cards. */
7430
+ /**
7431
+ * The "Browse all skills" dialog — a searchable grid of Org / Kodaris cards.
7432
+ *
7433
+ * The Kodaris tab is omitted on the registry server (kodaris.com): that instance
7434
+ * IS the catalog source, so `globalSkillList` has no remote to pull and always
7435
+ * returns `[]`. Its own published skills appear on the Your Org tab instead,
7436
+ * carrying a "Global" badge.
7437
+ */
7428
7438
  _renderSkillBrowse() {
7429
7439
  return b `
7430
7440
  <kr-dialog
@@ -7446,9 +7456,11 @@
7446
7456
  <kr-tab id="org" label="Your Org">
7447
7457
  ${this._renderSkillGrid()}
7448
7458
  </kr-tab>
7449
- <kr-tab id="kodaris" label="Kodaris">
7450
- ${this._renderSkillGrid(true)}
7451
- </kr-tab>
7459
+ ${this._aiFrameworkConfig.registryServer === true ? A : b `
7460
+ <kr-tab id="kodaris" label="Kodaris">
7461
+ ${this._renderSkillGrid(true)}
7462
+ </kr-tab>
7463
+ `}
7452
7464
  </kr-tab-group>
7453
7465
  </div>
7454
7466
  </kr-dialog>
@@ -7457,8 +7469,13 @@
7457
7469
  /**
7458
7470
  * The card grid of skill search results for the browse dialog. Clicking a card
7459
7471
  * inserts `/code ` into the composer. In the shared-Kodaris (`global`) tab each
7460
- * card also gets a footer row with an Enabled toggle that installs/removes the
7461
- * skill on this instance.
7472
+ * card also gets a footer row with an Install/Uninstall button.
7473
+ *
7474
+ * The Kodaris tab is the whole published catalog, deliberately NOT role-filtered so
7475
+ * users can discover skills they don't have yet. So a card there can be in one of two
7476
+ * non-runnable states — the user's roles don't allow it (`canRun: false`), or it isn't
7477
+ * installed here (not `enabled`). Neither looks clickable; clicking either explains
7478
+ * why via `_handleSkillSelect`.
7462
7479
  */
7463
7480
  _renderSkillGrid(global = false) {
7464
7481
  let skills = this._orgSkills;
@@ -7472,7 +7489,10 @@
7472
7489
  <div class="skill-browse__grid">
7473
7490
  ${skills.map((skill) => b `
7474
7491
  <div
7475
- class="skill-card"
7492
+ class=${e({
7493
+ 'skill-card': true,
7494
+ 'skill-card--unavailable': skill.canRun === false || (!!skill.global && !skill.enabled),
7495
+ })}
7476
7496
  role="button"
7477
7497
  tabindex="0"
7478
7498
  @click=${() => this._handleSkillSelect(skill)}
@@ -7482,6 +7502,9 @@
7482
7502
  ${skill.description ? b `
7483
7503
  <span class="skill-card__desc">${skill.description}</span>
7484
7504
  ` : A}
7505
+ ${!global && skill.global ? b `
7506
+ <span class="skill-card__badge">Global</span>
7507
+ ` : A}
7485
7508
  ${global ? b `
7486
7509
  <div class="skill-card__footer">
7487
7510
  <span class="skill-card__price">Free</span>
@@ -9351,6 +9374,30 @@
9351
9374
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
9352
9375
  }
9353
9376
 
9377
+ /* A Kodaris-tab card the user can't invoke — not in their roles, or not installed
9378
+ here yet. Clicking it does nothing, so it drops the pointer cursor and the hover
9379
+ highlight. The Install button inside keeps its own pointer cursor. */
9380
+ .skill-card--unavailable {
9381
+ cursor: default;
9382
+ }
9383
+
9384
+ .skill-card--unavailable:hover {
9385
+ border-color: #e5e7eb;
9386
+ box-shadow: none;
9387
+ }
9388
+
9389
+ /* Marks a globally-published skill listed on the Your Org tab — only happens on
9390
+ kodaris.com, which has no Kodaris tab for its own published skills. */
9391
+ .skill-card__badge {
9392
+ align-self: flex-start;
9393
+ padding: 2px 8px;
9394
+ border-radius: 10px;
9395
+ background: #f3f4f6;
9396
+ font-size: 11px;
9397
+ font-weight: 600;
9398
+ color: #000000;
9399
+ }
9400
+
9354
9401
  /* Footer row on global skill cards: install button on the left, price on the
9355
9402
  right. margin-top:auto pins it to the bottom so cards in a row align their
9356
9403
  footers regardless of description length. Negative horizontal margins