@asd20/ui 3.9.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui",
3
- "version": "3.9.0",
3
+ "version": "3.11.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "sideEffects": [
@@ -1,4 +1,4 @@
1
- <template>
1
+ \<template>
2
2
  <div
3
3
  ref="container"
4
4
  class="asd20-site-search"
@@ -8,47 +8,27 @@
8
8
  >
9
9
  <div class="asd20-site-search__viewport">
10
10
  <div class="asd20-site-search__options">
11
- <!-- Ask a Question -->
12
- <div
13
- class="asd20-site-search__field asd20-site-search__field--question"
14
- >
15
- <!-- <label class="asd20-site-search__label">Ask a question</label> -->
11
+ <!-- Unified Query Input -->
12
+ <div class="asd20-site-search__field asd20-site-search__field--question">
16
13
  <div class="asd20-site-search__question-row">
17
14
  <asd20-search-field
18
- idTag="-question"
19
- ref="question"
20
- v-model="questionText"
21
- @keyup.enter.native.stop.prevent="onAskQuestion"
22
- placeholder="Ask a question (preview)"
15
+ idTag="-unified"
16
+ ref="query"
17
+ v-model="inputText"
18
+ @keyup.enter.native.stop.prevent="onSubmitInput"
19
+ placeholder="Question (preview) or keywords..."
23
20
  />
24
21
  <asd20-button
25
22
  class="asd20-site-search__ask-button"
26
- label="Ask"
23
+ label="Go"
27
24
  size="md"
28
25
  bordered
29
- @click.native="onAskQuestion"
30
- :disabled="!questionText || searchingAi"
31
- />
32
- <asd20-loader
33
- v-if="searchingAi"
34
- size="sm"
35
- class="asd20-site-search__inline-loader"
26
+ reversed
27
+ @click.native="onSubmitInput"
28
+ :disabled="!inputText || searchingAi"
36
29
  />
37
30
  </div>
38
31
  </div>
39
- <hr />
40
-
41
- <!-- Search by Keyword -->
42
- <div class="asd20-site-search__field asd20-site-search__field--keyword">
43
- <!-- <label class="asd20-site-search__label">Search by keyword</label> -->
44
- <asd20-search-field
45
- idTag="-sitewide"
46
- ref="search"
47
- v-model="keywords"
48
- placeholder="Search by keyword"
49
- />
50
- </div>
51
-
52
32
  <!-- Only show school results -->
53
33
  <asd20-checkbox
54
34
  v-show="hasParentOrg"
@@ -61,17 +41,22 @@
61
41
 
62
42
  <asd20-tab-bar :tabs="tabs" @tabClick="onTabClick" />
63
43
 
64
- <asd20-viewport class="asd20-site-search__results" scrollable>
44
+ <asd20-viewport
45
+ ref="results"
46
+ class="asd20-site-search__results"
47
+ scrollable
48
+ >
65
49
  <asd20-notification
66
50
  v-if="
67
51
  !keywords &&
68
- !questionText &&
52
+ !inputText &&
69
53
  !searchingFiles &&
70
54
  !searchingPages &&
71
55
  !searchingAi
72
56
  "
73
- title="Ask a Question, or Search by Keyword"
74
- description="Use the top input for plain-language questions, then press 'Ask'; or search by keyword in the lower input box."
57
+ title="Ask a Question or Search by Keyword"
58
+ description="Asking a question will generate an AI answer. Search by for pages and files by entering keyword(s).
59
+ <hr/>Our AI functionality is currently in preview and may not always provide accurate answers."
75
60
  />
76
61
 
77
62
  <!-- Answers tab -->
@@ -87,15 +72,18 @@
87
72
  <div v-if="aiAnswer" class="asd20-site-search__ai-result">
88
73
  <h3>Answer</h3>
89
74
  <div class="asd20-site-search__ai-answer" v-html="aiAnswer" />
90
-
91
75
  <div
92
- v-if="aiGroupedSources.length"
93
- class="asd20-site-search__ai-sources"
76
+ v-if="aiKeywordsDisplay"
77
+ class="asd20-site-search__ai-keywords"
94
78
  >
95
- <h4>Sources</h4>
96
- <ul>
97
- <li v-for="group in aiGroupedSources" :key="group.hostLabel">
98
- <strong>Content from: {{ group.hostLabel }}</strong>
79
+ {{ aiKeywordsDisplay }}
80
+ </div>
81
+
82
+ <!--
83
+ <div v-if="aiGroupedSources.length" class="asd20-site-search__ai-sources">
84
+ <h3>Sources</h3>
85
+ <div class="asd20-site-search__ai-source-groups">
86
+ <div v-for="group in aiGroupedSources" :key="group.hostLabel">
99
87
  <ul class="asd20-site-search__ai-source-list">
100
88
  <li v-for="src in group.sources" :key="src.url || src.id">
101
89
  <a :href="src.url" target="_blank" rel="noreferrer">
@@ -103,9 +91,10 @@
103
91
  </a>
104
92
  </li>
105
93
  </ul>
106
- </li>
107
- </ul>
94
+ </div>
95
+ </div>
108
96
  </div>
97
+ -->
109
98
 
110
99
  <asd20-loader v-if="searchingAi" size="lg" />
111
100
  </div>
@@ -134,6 +123,13 @@
134
123
  />
135
124
  </asd20-list>
136
125
  <asd20-loader v-if="searchingPages" size="lg" />
126
+
127
+ <div
128
+ v-if="aiKeywordsDisplay"
129
+ class="asd20-site-search__ai-keywords"
130
+ >
131
+ {{ aiKeywordsDisplay }}
132
+ </div>
137
133
  </div>
138
134
 
139
135
  <!-- Files tab -->
@@ -204,6 +200,14 @@ import mapFilesToListItems from '../../../helpers/mapFilesToListItems'
204
200
  // Mixins
205
201
  import globalPropMixinFactory from '../../../mixins/globalPropMixinFactory.js'
206
202
 
203
+ const SUBDOMAIN_LABELS = {
204
+ rampart: 'Rampart High School',
205
+ pinecreek: 'Pine Creek High School',
206
+ dcchigh: 'Discovery Canyon Campus High School',
207
+ liberty: 'Liberty High School',
208
+ d20online: 'Academy Online High School',
209
+ }
210
+
207
211
  export default {
208
212
  name: 'Asd20SiteSearch',
209
213
 
@@ -269,16 +273,18 @@ export default {
269
273
 
270
274
  data: () => ({
271
275
  currentTab: 'Answers',
272
- // keyword search (classic)
276
+ // unified input
277
+ inputText: '',
273
278
  keywords: '',
274
- // question input (AI)
275
- questionText: '',
276
279
  aiAnswer: null,
277
280
  searchingAi: false,
278
281
  aiSources: [],
279
282
  selectedGroup: null,
280
283
  searchingPages: false,
281
284
  searchingFiles: false,
285
+ keywordsFromAi: false,
286
+ aiKeywordsDisplay: '',
287
+ skipKeywordWatcher: false,
282
288
  }),
283
289
 
284
290
  computed: {
@@ -349,13 +355,17 @@ export default {
349
355
  aiUiSources() {
350
356
  return (this.aiSources || []).map(src => ({
351
357
  ...src,
358
+ title: this.decodeHtml(src.title),
352
359
  hostLabel:
353
360
  this.resolveOrgTitleFromUrl(src.url) || this.labelFromUrl(src.url),
354
361
  }))
355
362
  },
356
363
  aiGroupedSources() {
357
364
  const groups = new Map()
358
- this.aiUiSources.forEach(src => {
365
+ const filteredSources = this.aiUiSources.filter((src) =>
366
+ this.isSourceCurrentOrg(src.url, src.hostLabel)
367
+ )
368
+ filteredSources.forEach(src => {
359
369
  const key = src.hostLabel || 'Academy District 20'
360
370
  if (!groups.has(key)) {
361
371
  groups.set(key, { hostLabel: key, sources: [] })
@@ -388,47 +398,60 @@ export default {
388
398
  active(newVal) {
389
399
  if (newVal) {
390
400
  // Focus keyword search by default for power users
391
- if (this.$refs.search && this.$refs.search.$el) {
392
- const input = this.$refs.search.$el.querySelector('input')
401
+ if (this.$refs.query && this.$refs.query.$el) {
402
+ const input = this.$refs.query.$el.querySelector('input')
393
403
  if (input) input.focus()
394
404
  }
395
- } else if (this.$refs.search && this.$refs.search.$el) {
396
- const input = this.$refs.search.$el.querySelector('input')
405
+ } else if (this.$refs.query && this.$refs.query.$el) {
406
+ const input = this.$refs.query.$el.querySelector('input')
397
407
  if (input) input.blur()
398
408
  }
399
409
  },
400
410
 
401
- // classic keyword search unchanged behavior
411
+ // keyword updates trigger searches unless explicitly skipped
402
412
  keywords: debounce(function (newVal, oldVal) {
413
+ if (this.skipKeywordWatcher) {
414
+ this.skipKeywordWatcher = false
415
+ return
416
+ }
403
417
  if (newVal !== oldVal) {
404
418
  this.searchPages()
405
419
  this.searchFiles()
406
420
  this.searchGroups()
407
421
  }
408
- }, 500),
422
+ }, 400),
409
423
 
410
424
  _includeDistrictResults() {
411
425
  this.searchPages()
412
426
  this.searchFiles()
413
427
  },
414
428
 
415
- // Clear AI answer if user clears the question
416
- questionText(newVal) {
429
+ // Clear AI answer if user clears the input
430
+ inputText(newVal) {
417
431
  if (!newVal) {
418
432
  this.aiAnswer = null
419
433
  this.aiSources = []
434
+ this.aiKeywordsDisplay = ''
420
435
  }
421
436
  },
422
437
  },
423
438
 
424
439
  methods: {
425
- async searchPages() {
440
+ async searchPages(options = {}) {
426
441
  if (this.$store && this.$store.state.search) {
442
+ const keepAnswersTab = options.keepAnswersTab || this.keywordsFromAi
427
443
  this.searchingPages = true
428
444
  await this.$store.dispatch('search/queryPages', this.keywords)
429
445
  this.searchingPages = false
430
- if (this.keywords && this.keywords.trim()) {
446
+ if (this.keywords && this.keywords.trim() && !keepAnswersTab) {
431
447
  this.currentTab = 'Pages'
448
+ this.scrollResultsToTop()
449
+ }
450
+ this.keywordsFromAi = false
451
+ if (!keepAnswersTab) {
452
+ this.aiAnswer = null
453
+ this.aiSources = []
454
+ this.aiKeywordsDisplay = ''
432
455
  }
433
456
  }
434
457
  },
@@ -449,6 +472,9 @@ export default {
449
472
 
450
473
  onTabClick(tab) {
451
474
  this.currentTab = tab.label
475
+ if (tab.label === 'Pages') {
476
+ this.scrollResultsToTop()
477
+ }
452
478
  },
453
479
 
454
480
  onTab(event) {
@@ -456,6 +482,16 @@ export default {
456
482
  this.dismiss()
457
483
  },
458
484
 
485
+ scrollResultsToTop() {
486
+ this.$nextTick(() => {
487
+ const ref = this.$refs.results
488
+ const el = ref ? ref.$el || ref : null
489
+ if (el && typeof el.scrollTop === 'number') {
490
+ el.scrollTop = 0
491
+ }
492
+ })
493
+ },
494
+
459
495
  onBackgroundClick(e) {
460
496
  if (e.target === this.$refs.container) this.dismiss()
461
497
  },
@@ -470,27 +506,16 @@ export default {
470
506
  labelFromUrl(url) {
471
507
  if (!url) return 'Academy District 20'
472
508
  try {
473
- const host = new URL(url).hostname
509
+ const host = new URL(url).hostname.toLowerCase()
474
510
 
475
511
  if (host === 'www.asd20.org') return 'Academy District 20'
476
512
 
477
513
  if (host.endsWith('.asd20.org')) {
478
514
  const subdomain = host.replace('.asd20.org', '')
479
- switch (subdomain) {
480
- case 'rampart':
481
- return 'Rampart High School'
482
- case 'pinecreek':
483
- return 'Pine Creek High School'
484
- case 'dcchigh':
485
- return 'Discovery Canyon Campus High School'
486
- case 'liberty':
487
- return 'Liberty High School'
488
- case 'd20online':
489
- return 'Academy Online High School'
490
- // add more mappings as needed
491
- default:
492
- return subdomain.charAt(0).toUpperCase() + subdomain.slice(1)
493
- }
515
+ return (
516
+ SUBDOMAIN_LABELS[subdomain] ||
517
+ subdomain.replace(/(^|[-_.])([a-z])/g, (_, __, c) => c.toUpperCase())
518
+ )
494
519
  }
495
520
 
496
521
  return host
@@ -519,15 +544,55 @@ export default {
519
544
  }
520
545
  },
521
546
 
522
- // Called when user clicks Ask or presses Enter in question field
523
- onAskQuestion() {
524
- const q = (this.questionText || '').trim()
525
- if (!q) {
547
+ isSourceCurrentOrg(url, hostLabel) {
548
+ const org = this.organization || {}
549
+ const orgTitle = (org.title || '').toLowerCase()
550
+ const orgWebsite = org.website || ''
551
+
552
+ // If org website is available, match by hostname
553
+ if (orgWebsite) {
554
+ try {
555
+ const orgHost = new URL(orgWebsite).hostname.toLowerCase()
556
+ const srcHost = url ? new URL(url).hostname.toLowerCase() : ''
557
+ if (orgHost && srcHost && (srcHost === orgHost || srcHost.endsWith(`.${orgHost}`))) {
558
+ return true
559
+ }
560
+ } catch {
561
+ // fall through to title comparison
562
+ }
563
+ }
564
+
565
+ // Fallback to title match
566
+ if (hostLabel && orgTitle) {
567
+ return hostLabel.toLowerCase() === orgTitle
568
+ }
569
+
570
+ // If no organization info, include nothing
571
+ return false
572
+ },
573
+
574
+ onSubmitInput() {
575
+ const text = (this.inputText || '').trim()
576
+ if (!text) {
526
577
  this.aiAnswer = null
527
578
  this.aiSources = []
579
+ this.aiKeywordsDisplay = ''
528
580
  return
529
581
  }
530
- this.searchAi(q)
582
+
583
+ const mode = this.decideMode(text)
584
+
585
+ if (mode === 'question') {
586
+ this.searchAi(text)
587
+ } else {
588
+ this.keywordsFromAi = false
589
+ this.skipKeywordWatcher = true
590
+ this.keywords = text
591
+ this.searchPages({ keepAnswersTab: false })
592
+ this.searchFiles()
593
+ this.searchGroups()
594
+ this.currentTab = 'Pages'
595
+ }
531
596
  },
532
597
 
533
598
  async searchAi(question) {
@@ -556,8 +621,33 @@ export default {
556
621
  }
557
622
  )
558
623
 
559
- this.aiAnswer = this.sanitizeAnswer(answer)
624
+ const { cleanAnswer, keywords: aiKeywords } =
625
+ this.extractKeywordsAndCleanAnswer(answer || '')
626
+
627
+ this.aiAnswer = this.sanitizeAnswer(cleanAnswer)
560
628
  this.aiSources = sources || []
629
+
630
+ const safeKeywords = this.sanitizeKeywords(aiKeywords)
631
+ const fallbackKeywords = safeKeywords
632
+ ? ''
633
+ : this.deriveFallbackKeywords(this.inputText)
634
+ const keywordsToUse = safeKeywords || fallbackKeywords
635
+
636
+ // Show the AI-provided keywords (or fallback) in brackets for visibility.
637
+ const displayKeywordsSource = aiKeywords || keywordsToUse || ''
638
+ this.aiKeywordsDisplay = displayKeywordsSource
639
+ ? `[Keywords: ${displayKeywordsSource.replace(/<[^>]*>/g, '')}]`
640
+ : ''
641
+
642
+ if (keywordsToUse) {
643
+ this.keywordsFromAi = true
644
+ this.skipKeywordWatcher = true
645
+ this.keywords = keywordsToUse
646
+ this.searchPages({ keepAnswersTab: true })
647
+ this.searchFiles()
648
+ this.searchGroups()
649
+ }
650
+
561
651
  this.currentTab = 'Answers'
562
652
  } catch (e) {
563
653
  console.error('AI search failed', e)
@@ -624,6 +714,162 @@ export default {
624
714
  cleanNode(container)
625
715
  return container.innerHTML
626
716
  },
717
+
718
+ extractKeywordsAndCleanAnswer(answerText) {
719
+ if (!answerText) return { cleanAnswer: '', keywords: '' }
720
+
721
+ // Grab the last occurrence of a keywords marker to reduce prompt-injection tricks.
722
+ // Accepts `[Keywords: ...]` or `Keywords: ...` without brackets.
723
+ const regex = /\[?\s*keywords[:\-\s]*([^\]\n\r]+)\]?/gi
724
+ let match
725
+ let lastMatch = null
726
+ while ((match = regex.exec(answerText)) !== null) {
727
+ lastMatch = match
728
+ }
729
+
730
+ const rawKeywords = lastMatch ? (lastMatch[1] || '').trim() : ''
731
+
732
+ // Keep the keywords block visible for now. To hide later, uncomment below:
733
+ const cleanAnswer = lastMatch
734
+ ? `${answerText.slice(0, lastMatch.index)}${answerText
735
+ .slice(lastMatch.index + lastMatch[0].length)
736
+ .trim()}`
737
+ : answerText
738
+
739
+ return { cleanAnswer: cleanAnswer.trim(), keywords: rawKeywords }
740
+ },
741
+
742
+ sanitizeKeywords(rawKeywords) {
743
+ if (!rawKeywords || typeof rawKeywords !== 'string') return ''
744
+
745
+ // Remove brackets, leading "keywords" label, and weird punctuation; keep basic word chars, spaces, hyphens, commas
746
+ const cleaned = rawKeywords
747
+ .replace(/<[^>]*>/g, ' ')
748
+ .replace(/[\[\]\(\)\{\}]/g, ' ')
749
+ .replace(/\bkeywords\b[:\-\s]*/i, '')
750
+ .replace(/[^a-z0-9,\-\s]/gi, ' ')
751
+ .trim()
752
+
753
+ if (!cleaned) return ''
754
+
755
+ // Split on commas/semicolons/pipes first; fall back to spaces
756
+ const parts =
757
+ cleaned.split(/[,;|]/).filter(Boolean).map(p => p.trim()).filter(Boolean)
758
+ const tokens =
759
+ parts.length > 0
760
+ ? parts
761
+ : cleaned.split(/\s+/).filter(Boolean)
762
+
763
+ const unique = []
764
+ for (const token of tokens) {
765
+ if (!token) continue
766
+ if (token.length <= 1) continue
767
+ const clipped = token.slice(0, 32)
768
+ if (!unique.includes(clipped)) {
769
+ unique.push(clipped)
770
+ }
771
+ if (unique.length >= 3) break
772
+ }
773
+
774
+ const result = unique.join(' ').slice(0, 100).trim()
775
+ return result
776
+ },
777
+
778
+ decodeHtml(value) {
779
+ if (!value || typeof value !== 'string') return value || ''
780
+ if (typeof window === 'undefined' || !window.document) return value
781
+ const parser = new DOMParser()
782
+ const decoded = parser.parseFromString(value, 'text/html').documentElement
783
+ .textContent
784
+ return decoded || value
785
+ },
786
+
787
+ decideMode(text) {
788
+ const lower = (text || '').toLowerCase()
789
+ if (!lower) return 'keyword'
790
+ if (lower.includes('?')) return 'question'
791
+
792
+ const questionStarters = [
793
+ 'who',
794
+ 'what',
795
+ 'when',
796
+ 'where',
797
+ 'why',
798
+ 'how',
799
+ 'should',
800
+ 'can',
801
+ 'could',
802
+ 'would',
803
+ 'is',
804
+ 'are',
805
+ 'do',
806
+ 'does',
807
+ 'will',
808
+ 'may',
809
+ ]
810
+ const words = lower.split(/\s+/).filter(Boolean)
811
+ if (words.length >= 7) return 'question'
812
+ if (words.length > 0 && questionStarters.includes(words[0])) {
813
+ return 'question'
814
+ }
815
+ return 'keyword'
816
+ },
817
+
818
+ deriveFallbackKeywords(questionText) {
819
+ if (!questionText) return ''
820
+ const STOP = new Set([
821
+ 'a',
822
+ 'an',
823
+ 'the',
824
+ 'and',
825
+ 'or',
826
+ 'of',
827
+ 'for',
828
+ 'to',
829
+ 'in',
830
+ 'on',
831
+ 'with',
832
+ 'at',
833
+ 'by',
834
+ 'from',
835
+ 'about',
836
+ 'as',
837
+ 'is',
838
+ 'are',
839
+ 'was',
840
+ 'were',
841
+ 'be',
842
+ 'been',
843
+ 'being',
844
+ 'this',
845
+ 'that',
846
+ 'these',
847
+ 'those',
848
+ 'it',
849
+ 'its',
850
+ 'your',
851
+ 'you',
852
+ 'we',
853
+ 'us',
854
+ 'our',
855
+ 'i',
856
+ 'me',
857
+ 'my',
858
+ ])
859
+ const tokens = questionText
860
+ .toLowerCase()
861
+ .replace(/[^a-z0-9\s]/g, ' ')
862
+ .split(/\s+/)
863
+ .filter(Boolean)
864
+ .filter((t) => !STOP.has(t))
865
+
866
+ const uniq = []
867
+ for (const t of tokens) {
868
+ if (!uniq.includes(t)) uniq.push(t)
869
+ if (uniq.length >= 3) break
870
+ }
871
+ return uniq.join(' ')
872
+ },
627
873
  },
628
874
  }
629
875
  </script>
@@ -722,8 +968,8 @@ export default {
722
968
  ::v-deep ol,
723
969
  ::v-deep ul {
724
970
  display: block !important;
725
- list-style-position: inside;
726
- padding-left: 0;
971
+ list-style-position: outside;
972
+ padding-left: 1.5rem;
727
973
  margin-left: 0;
728
974
  flex: 0 0 auto;
729
975
  flex-wrap: nowrap;
@@ -747,26 +993,28 @@ export default {
747
993
 
748
994
  ul {
749
995
  list-style: none;
750
- padding-left: 0;
751
996
  margin: 0;
752
997
  }
998
+ }
753
999
 
754
- li {
755
- font-size: 0.85rem;
756
- margin-bottom: space(0.25);
757
- }
1000
+ &__ai-source-groups {
1001
+ list-style: none;
1002
+ padding-left: 0;
1003
+ margin: 0;
758
1004
  }
759
1005
 
760
1006
  &__ai-source-list {
1007
+ display: block;
761
1008
  list-style: disc;
762
- list-style-position: inside;
763
- padding-left: 0;
764
- margin: space(0.25) 0 0.35rem 0;
1009
+ list-style-position: outside;
1010
+ padding-left: space(0.5);
1011
+ margin: 0 0 0.35rem space(0.5);
765
1012
 
766
1013
  li {
1014
+ list-style: disc;
1015
+ list-style-position: outside;
767
1016
  display: list-item;
768
1017
  margin: 0;
769
- padding: 0;
770
1018
  }
771
1019
  }
772
1020