Skip to content

Commit e4faa92

Browse files
committed
Add new AI Search feature
1 parent 3a30504 commit e4faa92

File tree

6 files changed

+715
-12
lines changed

6 files changed

+715
-12
lines changed

MyApp/Pages/Shared/Header.cshtml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
("/ai-server/", "AI Server")
1313
};
1414
}
15-
<header id="header" class="top-0 fixed z-50 h-14 bg-white dark:bg-black opacity-90 w-full shadow">
15+
<header id="header" class="top-0 fixed z-50 h-14 bg-white/90 dark:bg-black/90 w-full shadow">
1616
<nav class="border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-black">
1717
<div class="mx-auto max-w-[100rem] pr-4 sm:pr-6 lg:pr-8">
1818
<div class="flex h-16 justify-between">

MyApp/Pages/Shared/_Layout.cshtml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
["vue"] = ("/lib/mjs/vue.mjs", "/lib/mjs/vue.min.mjs"),
3030
["@servicestack/client"] = ("/lib/mjs/servicestack-client.mjs", "/lib/mjs/servicestack-client.min.mjs"),
3131
["@servicestack/vue"] = ("/lib/mjs/servicestack-vue.mjs", "/lib/mjs/servicestack-vue.min.mjs"),
32+
["marked"] = ("/lib/mjs/marked.min.mjs", "/lib/mjs/marked.min.mjs"),
33+
["highlight.js"] = ("/lib/mjs/highlight.min.mjs", "/lib/mjs/highlight.min.mjs"),
3234
})
3335
</head>
3436
<body class="bg-white dark:bg-black dark:text-white">

MyApp/wwwroot/lib/mjs/markdown.mjs

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { Marked } from "marked"
2+
import hljs from "highlight.js"
3+
4+
export const marked = (() => {
5+
const aliases = {
6+
vue: 'html',
7+
}
8+
9+
const ret = new Marked(
10+
markedHighlight({
11+
langPrefix: 'hljs language-',
12+
highlight(code, lang, info) {
13+
if (aliases[lang]) {
14+
lang = aliases[lang]
15+
}
16+
const language = hljs.getLanguage(lang) ? lang : 'plaintext'
17+
return hljs.highlight(code, { language }).value
18+
}
19+
})
20+
)
21+
ret.use({ extensions:[thinkTag()] })
22+
//ret.use({ extensions: [divExtension()] })
23+
return ret
24+
})();
25+
26+
export function renderMarkdown(content) {
27+
if (content) {
28+
content = content
29+
.replaceAll(`\\[ \\boxed{`,'\n<span class="inline-block text-xl text-blue-500 bg-blue-50 dark:text-blue-400 dark:bg-blue-950 px-3 py-1 rounded">')
30+
.replaceAll('} \\]','</span>\n')
31+
}
32+
return marked.parse(content)
33+
}
34+
35+
// export async function renderMarkdown(body) {
36+
// const rawHtml = marked.parse(body)
37+
// return <main dangerouslySetInnerHTML={{ __html: rawHtml }} />
38+
// }
39+
40+
export function markedHighlight(options) {
41+
if (typeof options === 'function') {
42+
options = {
43+
highlight: options
44+
}
45+
}
46+
47+
if (!options || typeof options.highlight !== 'function') {
48+
throw new Error('Must provide highlight function')
49+
}
50+
51+
if (typeof options.langPrefix !== 'string') {
52+
options.langPrefix = 'language-'
53+
}
54+
55+
return {
56+
async: !!options.async,
57+
walkTokens(token) {
58+
if (token.type !== 'code') {
59+
return
60+
}
61+
62+
const lang = getLang(token.lang)
63+
64+
if (options.async) {
65+
return Promise.resolve(options.highlight(token.text, lang, token.lang || '')).then(updateToken(token))
66+
}
67+
68+
const code = options.highlight(token.text, lang, token.lang || '')
69+
if (code instanceof Promise) {
70+
throw new Error('markedHighlight is not set to async but the highlight function is async. Set the async option to true on markedHighlight to await the async highlight function.')
71+
}
72+
updateToken(token)(code)
73+
},
74+
renderer: {
75+
code(code, infoString) {
76+
const lang = getLang(infoString)
77+
let text = code.text
78+
const classAttr = lang
79+
? ` class="${options.langPrefix}${escape(lang)}"`
80+
: ' class="hljs"';
81+
text = text.replace(/\n$/, '')
82+
return `<pre><code${classAttr}>${code.escaped ? text : escape(text, true)}\n</code></pre>`
83+
}
84+
}
85+
}
86+
}
87+
88+
function getLang(lang) {
89+
return (lang || '').match(/\S*/)[0]
90+
}
91+
92+
function updateToken(token) {
93+
return code => {
94+
if (typeof code === 'string' && code !== token.text) {
95+
token.escaped = true
96+
token.text = code
97+
}
98+
}
99+
}
100+
101+
// copied from marked helpers
102+
const escapeTest = /[&<>"']/
103+
const escapeReplace = new RegExp(escapeTest.source, 'g')
104+
const escapeTestNoEncode = /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/
105+
const escapeReplaceNoEncode = new RegExp(escapeTestNoEncode.source, 'g')
106+
const escapeReplacements = {
107+
'&': '&amp;',
108+
'<': '&lt;',
109+
'>': '&gt;',
110+
'"': '&quot;',
111+
"'": '&#39;'
112+
}
113+
const getEscapeReplacement = ch => escapeReplacements[ch]
114+
function escape(html, encode) {
115+
if (encode) {
116+
if (escapeTest.test(html)) {
117+
return html.replace(escapeReplace, getEscapeReplacement)
118+
}
119+
} else {
120+
if (escapeTestNoEncode.test(html)) {
121+
return html.replace(escapeReplaceNoEncode, getEscapeReplacement)
122+
}
123+
}
124+
125+
return html
126+
}
127+
128+
/**
129+
* Marked.js extension for rendering <think> tags as expandable, scrollable components
130+
* using Tailwind CSS
131+
*/
132+
133+
// Extension for Marked.js to handle <think> tags
134+
function thinkTag() {
135+
globalThis.toggleThink = toggleThink
136+
return ({
137+
name: 'thinkTag',
138+
level: 'block',
139+
start(src) {
140+
return src.match(/^<think>/)?.index;
141+
},
142+
tokenizer(src) {
143+
const rule = /^<think>([\s\S]*?)<\/think>/
144+
const match = rule.exec(src)
145+
if (match) {
146+
return {
147+
type: 'thinkTag',
148+
raw: match[0],
149+
content: match[1].trim(),
150+
}
151+
}
152+
return undefined
153+
},
154+
renderer(token) {
155+
// Parse the markdown content inside the think tag
156+
const parsedContent = marked.parse(token.content)
157+
158+
// Generate a unique ID for this think component
159+
const uniqueId = 'think-' + Math.random().toString(36).substring(2, 10)
160+
161+
// Create the expandable, scrollable component with Tailwind CSS
162+
return `
163+
<div class="my-4 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm">
164+
<button type="button"
165+
id="${uniqueId}-toggle"
166+
class="flex justify-between items-center w-full py-2 px-4 text-left text-gray-700 dark:text-gray-300 font-medium hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none"
167+
onclick="toggleThink('${uniqueId}')">
168+
<span>Thinking</span>
169+
<svg id="${uniqueId}-icon" class="h-5 w-5 text-gray-500 dark:text-gray-400 transform transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor">
170+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
171+
</svg>
172+
</button>
173+
<div
174+
id="${uniqueId}-content"
175+
class="hidden overflow-auto max-h-64 px-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900"
176+
style="max-height:16rem;">
177+
${parsedContent}
178+
</div>
179+
</div>
180+
`
181+
}
182+
})
183+
}
184+
185+
// JavaScript function to toggle the visibility of the think content
186+
function toggleThink(id) {
187+
const content = document.getElementById(`${id}-content`)
188+
const icon = document.getElementById(`${id}-icon`)
189+
190+
if (content.classList.contains('hidden')) {
191+
content.classList.remove('hidden')
192+
icon.classList.add('rotate-180')
193+
} else {
194+
content.classList.add('hidden')
195+
icon.classList.remove('rotate-180')
196+
}
197+
}

MyApp/wwwroot/mjs/components/Typesense.mjs

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { ref, nextTick, onMounted } from 'vue'
2+
import TypesenseConversation from './TypesenseConversation.mjs'
23

34
const TypesenseDialog = {
4-
template:`<div class="search-dialog hidden flex bg-black bg-opacity-25 items-center" :class="{ open }"
5+
template:`<div class="search-dialog hidden flex bg-black/25 items-center" :class="{ open }"
56
@click="$emit('hide')">
67
<div class="dialog absolute w-full flex flex-col bg-white dark:bg-gray-800" style="max-height:70vh;" @click.stop="">
78
<div class="p-2 flex flex-col" style="max-height: 70vh;">
@@ -201,19 +202,23 @@ const TypesenseDialog = {
201202
export default {
202203
components: {
203204
TypesenseDialog,
205+
TypesenseConversation,
204206
},
205207
template:`<div>
206208
<TypesenseDialog :open="openSearch" @hide="hideSearch" />
207-
<button class="flex rounded-full p-0 bg-gray-100 dark:bg-gray-800 border-2 border-solid border-gray-100 dark:border-gray-700 text-gray-400 cursor-pointer
208-
hover:border-green-400 dark:hover:border-green-400 hover:bg-white hover:text-gray-600" @click="showSearch">
209-
<svg class="w-7 h-7 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
210-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16l2.879-2.879m0 0a3 3 0 104.243-4.242 3 3 0 00-4.243 4.242zM21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
211-
</svg>
212-
<span class="hidden xl:inline text-lg mr-1">Search</span>
213-
<span style="opacity:1;" class="hidden md:block text-gray-400 text-sm leading-5 py-0 px-1.5 my-0.5 mr-1.5 border border-gray-300 border-solid rounded-md">
214-
<span class="sr-only">Press </span><kbd class="font-sans">/</kbd><span class="sr-only"> to search</span>
215-
</span>
216-
</button>
209+
<div class="flex space-x-2">
210+
<TypesenseConversation />
211+
<button class="flex rounded-full p-0 bg-gray-100 dark:bg-gray-800 border-2 border-solid border-gray-100 dark:border-gray-700 text-gray-400 cursor-pointer
212+
hover:border-green-400 dark:hover:border-green-400 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-600" @click="showSearch">
213+
<svg class="w-7 h-7 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
214+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16l2.879-2.879m0 0a3 3 0 104.243-4.242 3 3 0 00-4.243 4.242zM21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
215+
</svg>
216+
<span class="hidden xl:inline text-lg mr-1">Search</span>
217+
<span style="opacity:1;" class="hidden md:block text-gray-400 text-sm leading-5 py-0 px-1.5 my-0.5 mr-1.5 border border-gray-300 border-solid rounded-md">
218+
<span class="sr-only">Press </span><kbd class="font-sans">/</kbd><span class="sr-only"> to search</span>
219+
</span>
220+
</button>
221+
</div>
217222
</div>
218223
`,
219224
setup() {

0 commit comments

Comments
 (0)