Skip to content

Commit 2ff24c6

Browse files
committed
AI Search UI tweaks + parse + replace references
1 parent 1140401 commit 2ff24c6

File tree

2 files changed

+179
-23
lines changed

2 files changed

+179
-23
lines changed

MyApp/wwwroot/lib/mjs/markdown.mjs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,26 @@ export function renderMarkdown(content) {
3232
return marked.parse(content)
3333
}
3434

35+
/**
36+
* HTML encodes a string to safely embed it in HTML attributes.
37+
*
38+
* @param {string} text - The text to encode
39+
* @returns {string} HTML encoded text
40+
*/
41+
export function htmlEncode(text) {
42+
if (!text) return text
43+
44+
const map = {
45+
'&': '&',
46+
'<': '&lt;',
47+
'>': '&gt;',
48+
'"': '&quot;',
49+
"'": '&#39;'
50+
}
51+
52+
return text.replace(/[&<>"']/g, char => map[char])
53+
}
54+
3555
// export async function renderMarkdown(body) {
3656
// const rawHtml = marked.parse(body)
3757
// return <main dangerouslySetInnerHTML={{ __html: rawHtml }} />

MyApp/wwwroot/mjs/components/TypesenseConversation.mjs

Lines changed: 159 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ref, nextTick, onMounted, watch } from 'vue'
2-
import { renderMarkdown } from "../../lib/mjs/markdown.mjs"
2+
import { renderMarkdown, htmlEncode } from "../../lib/mjs/markdown.mjs"
33

44
const collection = 'typesense_docs'
55
const BaseUrl = "https://search.docs.servicestack.net";
@@ -65,6 +65,7 @@ async function multiSearch(message, conversationId = null) {
6565
exclude_fields: "embedding"
6666
}]
6767
})
68+
6869
const result = response.results[0]
6970
const { answer, conversation_id, query } = response.conversation
7071
const { found, out_of, page, request_params, search_cutoff, search_time_ms } = result
@@ -80,6 +81,8 @@ async function multiSearch(message, conversationId = null) {
8081
search_time_ms,
8182
results: response.results.length,
8283
hits: result.hits.map((x) => ({
84+
id: x.document.id,
85+
objectID: x.document.objectID,
8386
url: x.document.url,
8487
anchor: x.document.anchor,
8588
content: clean(x.document.content),
@@ -94,21 +97,40 @@ async function multiSearch(message, conversationId = null) {
9497
}
9598

9699
const AISearchDialog = {
97-
template: `<div v-if="open" class="search-dialog fixed inset-0 z-50 flex bg-black/25 items-center justify-center" @click="$emit('hide')">
100+
template: `<div v-if="open" class="search-dialog fixed inset-0 z-50 flex bg-black/25 items-center justify-center" @click="$emit('hide')" @keydown.escape="$emit('hide')">
98101
<div class="dialog absolute w-full max-w-2xl flex flex-col bg-indigo-50 dark:bg-indigo-900 rounded-lg shadow-lg" style="max-height:80vh;" @click.stop="">
99102
<div class="p-4 flex flex-col" style="max-height: 80vh;">
100103
<!-- Header -->
101104
<div class="flex items-center justify-between mb-4 bg-indigo-900 dark:bg-indigo-950 p-4 -m-4 mb-4 rounded-t-lg">
102-
<h2 class="text-xl font-semibold text-white">Ask ServiceStack Docs</h2>
103-
<button type="button" @click="$emit('hide')" class="text-gray-400 hover:text-white dark:hover:text-white">
104-
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
105-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
106-
</svg>
107-
</button>
105+
<div class="flex items-center gap-2">
106+
<h2 class="text-xl font-semibold text-white">Ask ServiceStack Docs</h2>
107+
</div>
108+
<div class="flex space-x-3">
109+
<button type="button" v-if="messages.length > 0" @click="clearConversation"
110+
class="text-xs text-gray-300 hover:text-white dark:hover:text-white font-semibold">
111+
<svg class="inline-block size-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M14 11v6m-4-6v6M6 7v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7M4 7h16M7 7l2-4h6l2 4" stroke-width="1"/></svg>
112+
clear
113+
</button>
114+
<button type="button" @click="$emit('hide')" class="text-gray-400 hover:text-white dark:hover:text-white">
115+
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
116+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
117+
</svg>
118+
</button>
119+
</div>
120+
</div>
121+
122+
<!-- Suggestions -->
123+
<div v-if="messages.length === 0" class="mb-4">
124+
<p class="text-xs text-gray-600 dark:text-gray-400 font-semibold mb-2">Suggestions:</p>
125+
<div class="flex flex-wrap gap-2">
126+
<button v-for="suggestion in suggestions" :key="suggestion" type="button" @click="inputMessage = suggestion; sendMessage()" class="text-xs px-3 py-1 bg-blue-100 dark:bg-blue-900 border border-blue-400 dark:border-blue-600 text-blue-700 dark:text-blue-200 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-full transition-colors">
127+
{{ suggestion }}
128+
</button>
129+
</div>
108130
</div>
109131
110132
<!-- Messages Area -->
111-
<div class="flex-1 overflow-y-auto mb-4 pr-2 space-y-4" style="max-height: calc(80vh - 180px);">
133+
<div class="flex-1 overflow-y-auto mb-4 pr-2 space-y-4 border-b border-gray-300 dark:border-gray-600" style="max-height: calc(80vh - 180px);">
112134
<div v-for="(msg, idx) in messages" :key="idx" :class="['message', msg.role === 'user' ? 'user-message' : 'assistant-message']">
113135
<div v-if="msg.role === 'user'" class="flex justify-end">
114136
<div class="bg-indigo-900 dark:bg-indigo-950 text-white rounded-lg px-4 py-2 max-w-xs">
@@ -117,14 +139,11 @@ const AISearchDialog = {
117139
</div>
118140
<div v-else class="flex flex-col">
119141
<div class="shadow prose bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white rounded-lg px-4 py-2 mb-3"
120-
v-html="renderMarkdown(msg.content)"></div>
142+
v-html="renderContent(msg)"></div>
121143
<!-- Search Results -->
122144
<div v-if="getUniqueHits(msg.hits).length > 0" class="space-y-3 mt-3">
123145
<div class="flex items-center justify-between gap-2">
124146
<p class="text-sm text-gray-600 dark:text-gray-400 font-semibold">{{ getUniqueHits(msg.hits).length }} Result{{ getUniqueHits(msg.hits).length !== 1 ? 's' : '' }} Found</p>
125-
<button type="button" @click="clearConversation" class="text-xs text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 hover:underline font-semibold">
126-
clear
127-
</button>
128147
</div>
129148
<a v-for="(hit, hitIdx) in getUniqueHits(msg.hits)" :key="hitIdx" :href="hit.url" :class="[hit.type ===
130149
'lvl0' || hit.type === 'lvl1' ?
@@ -141,22 +160,16 @@ const AISearchDialog = {
141160
{{ hit.content }}
142161
</p>
143162
</a>
144-
<!-- Clear Button After Results -->
145-
<div class="flex justify-center pt-2">
146-
<button type="button" @click="clearConversation" class="text-xs text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 hover:underline font-semibold">
147-
clear
148-
</button>
149-
</div>
150163
</div>
151164
</div>
152165
</div>
153-
<div v-if="loading" class="flex justify-center min-h-10">
166+
<div v-if="loading" class="mb-2 flex justify-center min-h-10">
154167
<div class="absolute animate-spin rounded-full size-8 border-b-2 border-blue-500 dark:border-blue-400"></div>
155168
</div>
156169
</div>
157170
158171
<!-- Input Area -->
159-
<div class="flex gap-2 pt-4">
172+
<div class="flex gap-2">
160173
<input ref="refMessage"
161174
v-model="inputMessage"
162175
@keyup.enter="sendMessage"
@@ -184,6 +197,11 @@ const AISearchDialog = {
184197
const inputMessage = ref('')
185198
const loading = ref(false)
186199
const conversationId = ref(null)
200+
const suggestions = [
201+
"What is AutoQuery?",
202+
"How do I Authenticate?",
203+
"What Add ServiceStack Reference languages are supported?"
204+
]
187205

188206
function getUniqueHits(hits) {
189207
if (!hits) return []
@@ -234,7 +252,12 @@ const AISearchDialog = {
234252
nextTick(() => {
235253
const messagesArea = document.querySelector('.search-dialog .overflow-y-auto')
236254
if (messagesArea) {
237-
messagesArea.scrollTop = messagesArea.scrollHeight
255+
// Scroll to the user message (second to last message)
256+
const messages = messagesArea.querySelectorAll('.message')
257+
if (messages.length >= 2) {
258+
const userMessage = messages[messages.length - 2]
259+
userMessage.scrollIntoView({ behavior: 'smooth', block: 'start' })
260+
}
238261
}
239262
})
240263
}
@@ -248,15 +271,33 @@ const AISearchDialog = {
248271
}
249272
})
250273

274+
function renderContent(msg) {
275+
console.log('msg', JSON.stringify(msg, null, 2))
276+
277+
let content = msg.content
278+
if (content.includes('${[')) {
279+
content = content.replaceAll(/\$\{(\[.*?\])\}/g, (match, group) => {
280+
const ids = extractAllReferenceIds(group)
281+
return ids.join(', ')
282+
})
283+
}
284+
285+
content = parseAndReplaceReferences(msg.content, msg.hits)
286+
287+
const html = renderMarkdown(content)
288+
return html
289+
}
290+
251291
return {
252292
refMessage,
253293
messages,
254294
inputMessage,
255295
loading,
256296
sendMessage,
257297
clearConversation,
258-
renderMarkdown,
298+
renderContent,
259299
getUniqueHits,
300+
suggestions,
260301
}
261302
}
262303
}
@@ -303,3 +344,98 @@ export default {
303344
}
304345
}
305346
}
347+
348+
function getHit(hits, id) {
349+
return id && hits.find((x) => x.id == id || x.objectID == id)
350+
}
351+
352+
function refHtml(hit) {
353+
return `<a href="${hit.url}" target="_blank" class="svg-external" title="${htmlEncode(hit.title)}">${hit.id}</a>`
354+
}
355+
356+
/**
357+
* Parses markdown for reference patterns and replaces them with formatted links.
358+
* Supports patterns like: (Ref: 1234), (Ref. 1234), (id: 1234), (1234), [ref: 1234], [1234], [[1234]], etc.
359+
* Also supports 40-character UUIDs like [33ffd70e82ebd01b19dadc908ea097844c6fb013]
360+
*
361+
* @param {string} markdown - The markdown content to parse
362+
* @param {Object.<string, {id: string, title: string}>} hits - Dictionary of search results keyed by id
363+
* @param {Function} [replaceFn] - Optional callback function that receives the search result and returns HTML.
364+
* If not provided, uses default formatting: <a class="svg-external" title="{title}">{id}</a>
365+
* Signature: (searchResult) => string
366+
* @returns {string} Markdown with references replaced by formatted links
367+
*/
368+
export function parseAndReplaceReferences(markdown, hits, replaceFn) {
369+
if (!markdown || !hits || hits.length === 0) {
370+
return markdown
371+
}
372+
373+
// Regex pattern to match various reference formats:
374+
// - (Ref: 1234), (Ref. 1234), (Reference: 1234), (References: 1234), (id: 1234), (1234)
375+
// - [ref: 1234], [1234], [[1234]]
376+
// - [40-char-uuid] like [33ffd70e82ebd01b19dadc908ea097844c6fb013]
377+
// - Multiple references: (Ref. 1234, 1235) - extracts the first id
378+
const referencePattern = /\((?:(?:Ref(?:erence)?s?\.?|id):\s*)?(\d+)(?:\s*,\s*\d+)*\)|\[(?:ref:\s*)?(\d+)\]|\[\[(\d+)\]\]|\[([a-f0-9]{40})(?:\s*,\s*[a-f0-9]{40})*\]/gi
379+
380+
return markdown.replace(referencePattern, (match, group1, group2, group3, group4) => {
381+
// Extract the first captured group that matched
382+
const id = group1 || group2 || group3 || group4
383+
384+
// If we found an id and it exists in search results, replace with formatted link
385+
const hit = getHit(hits, id)
386+
if (hit) {
387+
console.log('found', id, hit)
388+
389+
// Use custom replaceFn if provided, otherwise use default formatting
390+
if (replaceFn && typeof replaceFn === 'function') {
391+
return replaceFn(hit)
392+
}
393+
394+
// Default formatting
395+
return refHtml(hit)
396+
} else {
397+
console.log('not found', id)
398+
}
399+
400+
// Return original match if no search result found
401+
return match
402+
})
403+
}
404+
405+
/**
406+
* Extracts the first reference id from markdown content.
407+
*
408+
* @param {string} markdown - The markdown content to parse
409+
* @returns {string|null} The first reference id found, or null if none found
410+
*/
411+
export function extractFirstReferenceId(markdown) {
412+
if (!markdown) return null
413+
414+
const referencePattern = /\((?:(?:Ref(?:erence)?s?\.?|id):\s*)?(\d+)(?:\s*,\s*\d+)*\)|\[(?:ref:\s*)?(\d+)\]|\[\[(\d+)\]\]|\[([a-f0-9]{40})\]/i
415+
const match = markdown.match(referencePattern)
416+
417+
if (!match) return null
418+
419+
return match[1] || match[2] || match[3] || match[4]
420+
}
421+
422+
/**
423+
* Extracts all reference ids from markdown content.
424+
*
425+
* @param {string} markdown - The markdown content to parse
426+
* @returns {string[]} List of all unique reference ids found
427+
*/
428+
export function extractAllReferenceIds(markdown) {
429+
if (!markdown) return []
430+
431+
const referencePattern = /\((?:(?:Ref(?:erence)?s?\.?|id):\s*)?(\d+)(?:\s*,\s*\d+)*\)|\[(?:ref:\s*)?(\d+)\]|\[\[(\d+)\]\]|\[([a-f0-9]{40})\]/gi
432+
const ids = new Set()
433+
let match
434+
435+
while ((match = referencePattern.exec(markdown)) !== null) {
436+
const id = match[1] || match[2] || match[3] || match[4]
437+
if (id) ids.add(id)
438+
}
439+
440+
return Array.from(ids)
441+
}

0 commit comments

Comments
 (0)