@mindfiredigital/ignix-lite-engine 1.1.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/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +283 -0
- package/dist/index.d.ts +171 -0
- package/dist/index.js +2540 -0
- package/dist/index.js.map +1 -0
- package/dist/manifests/accordion.json +61 -0
- package/dist/manifests/alert.json +69 -0
- package/dist/manifests/avatar.json +75 -0
- package/dist/manifests/badge.json +74 -0
- package/dist/manifests/breadcrumb.json +87 -0
- package/dist/manifests/button.json +85 -0
- package/dist/manifests/card.json +91 -0
- package/dist/manifests/checkbox.json +122 -0
- package/dist/manifests/codeblock.json +63 -0
- package/dist/manifests/combobox.json +33 -0
- package/dist/manifests/dialog.json +64 -0
- package/dist/manifests/divider.json +47 -0
- package/dist/manifests/dropdown.json +105 -0
- package/dist/manifests/form.json +81 -0
- package/dist/manifests/grid.json +143 -0
- package/dist/manifests/input.json +99 -0
- package/dist/manifests/meter.json +103 -0
- package/dist/manifests/navigation.json +70 -0
- package/dist/manifests/progress.json +88 -0
- package/dist/manifests/radio.json +121 -0
- package/dist/manifests/select.json +109 -0
- package/dist/manifests/skeleton.json +101 -0
- package/dist/manifests/tab.json +88 -0
- package/dist/manifests/table.json +92 -0
- package/dist/manifests/textarea.json +117 -0
- package/dist/manifests/toast.json +157 -0
- package/dist/manifests/tooltip.json +115 -0
- package/dist/vector-index.json +14015 -0
- package/package.json +33 -0
- package/src/global.d.ts +3 -0
- package/src/index.ts +14 -0
- package/src/manifests/accordion.json +61 -0
- package/src/manifests/alert.json +69 -0
- package/src/manifests/avatar.json +75 -0
- package/src/manifests/badge.json +74 -0
- package/src/manifests/breadcrumb.json +87 -0
- package/src/manifests/button.json +85 -0
- package/src/manifests/card.json +91 -0
- package/src/manifests/checkbox.json +122 -0
- package/src/manifests/codeblock.json +63 -0
- package/src/manifests/combobox.json +33 -0
- package/src/manifests/dialog.json +64 -0
- package/src/manifests/divider.json +47 -0
- package/src/manifests/dropdown.json +105 -0
- package/src/manifests/form.json +81 -0
- package/src/manifests/grid.json +143 -0
- package/src/manifests/index.ts +49 -0
- package/src/manifests/input.json +99 -0
- package/src/manifests/meter.json +103 -0
- package/src/manifests/navigation.json +70 -0
- package/src/manifests/progress.json +88 -0
- package/src/manifests/radio.json +121 -0
- package/src/manifests/select.json +109 -0
- package/src/manifests/skeleton.json +101 -0
- package/src/manifests/tab.json +88 -0
- package/src/manifests/table.json +92 -0
- package/src/manifests/textarea.json +117 -0
- package/src/manifests/toast.json +157 -0
- package/src/manifests/tooltip.json +115 -0
- package/src/tools/build-index.ts +43 -0
- package/src/tools/check-a11y.ts +96 -0
- package/src/tools/embedder.ts +18 -0
- package/src/tools/generate-theme.ts +42 -0
- package/src/tools/get-emmet.ts +64 -0
- package/src/tools/get-manifests.ts +55 -0
- package/src/tools/handoff.ts +302 -0
- package/src/tools/intent-engine.ts +215 -0
- package/src/tools/list-components.ts +20 -0
- package/src/tools/preview.ts +186 -0
- package/src/tools/search-index.ts +82 -0
- package/src/tools/theme-palette.ts +65 -0
- package/src/tools/theme-tokens.ts +176 -0
- package/src/tools/token-counter.ts +59 -0
- package/src/tools/validator.ts +353 -0
- package/src/types.ts +63 -0
- package/src/utils/a11y-rules.ts +873 -0
- package/src/utils/a11y-types.ts +15 -0
- package/src/utils/cosine.ts +15 -0
- package/src/utils/emmet-helpers.ts +283 -0
- package/src/utils/intent-helpers.ts +66 -0
- package/src/utils/intent-parser.ts +175 -0
- package/src/utils/tokenizer.ts +7 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +10 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { searchIndex } from './search-index.js'
|
|
2
|
+
import { getIntentEntries, scoreEntry } from '../utils/intent-parser.js'
|
|
3
|
+
import { getTokenCount } from '../utils/tokenizer.js'
|
|
4
|
+
import { tokenise } from '../utils/intent-helpers.js'
|
|
5
|
+
import {
|
|
6
|
+
expandEmmet,
|
|
7
|
+
extractComponents,
|
|
8
|
+
interpolateEmmet
|
|
9
|
+
} from '../utils/emmet-helpers.js'
|
|
10
|
+
|
|
11
|
+
import type { IntentEntry } from '../utils/intent-parser.js'
|
|
12
|
+
import type { MCPResponse } from '../types.js'
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
type IntentMatch = {
|
|
16
|
+
name: string
|
|
17
|
+
emmet: string
|
|
18
|
+
score: number
|
|
19
|
+
source: 'intent-table' | 'vector-index'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const LAYER1_THRESHOLD = 15 // minimum score to trust an intent-table hit
|
|
23
|
+
|
|
24
|
+
function searchIntentTable(description: string): IntentMatch | null {
|
|
25
|
+
const entries = getIntentEntries()
|
|
26
|
+
if (entries.length === 0) return null
|
|
27
|
+
|
|
28
|
+
const isStitchRequested =
|
|
29
|
+
/\b(and|plus|\+)\b/i.test(description) || description.includes(',')
|
|
30
|
+
|
|
31
|
+
const queryWords = tokenise(description)
|
|
32
|
+
|
|
33
|
+
const candidates: {
|
|
34
|
+
entry: IntentEntry
|
|
35
|
+
score: number
|
|
36
|
+
density: number
|
|
37
|
+
components: string[]
|
|
38
|
+
}[] = []
|
|
39
|
+
let bestSingle: (IntentMatch & { density: number }) | null = null
|
|
40
|
+
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
const { score, density } = scoreEntry(entry, queryWords)
|
|
43
|
+
if (score >= LAYER1_THRESHOLD) {
|
|
44
|
+
const components = extractComponents(entry.emmet)
|
|
45
|
+
candidates.push({ entry, score, density, components })
|
|
46
|
+
|
|
47
|
+
const isBetter =
|
|
48
|
+
!bestSingle ||
|
|
49
|
+
score > bestSingle.score ||
|
|
50
|
+
(score === bestSingle.score && density > bestSingle.density)
|
|
51
|
+
if (isBetter) {
|
|
52
|
+
bestSingle = {
|
|
53
|
+
name: entry.name,
|
|
54
|
+
emmet: entry.emmet,
|
|
55
|
+
score,
|
|
56
|
+
density,
|
|
57
|
+
source: 'intent-table'
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (bestSingle && bestSingle.score >= 18) {
|
|
64
|
+
if (!isStitchRequested || bestSingle.score >= 25) {
|
|
65
|
+
return bestSingle
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (isStitchRequested && candidates.length >= 2) {
|
|
70
|
+
candidates.sort((a, b) => b.score - a.score || b.density - a.density)
|
|
71
|
+
|
|
72
|
+
const selected: typeof candidates = []
|
|
73
|
+
const usedComponents = new Set<string>()
|
|
74
|
+
|
|
75
|
+
for (const c of candidates) {
|
|
76
|
+
const hasOverlap = c.components.some((comp) => usedComponents.has(comp))
|
|
77
|
+
if (!hasOverlap) {
|
|
78
|
+
selected.push(c)
|
|
79
|
+
c.components.forEach((comp) => usedComponents.add(comp))
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (selected.length >= 2) {
|
|
84
|
+
selected.sort((a, b) => {
|
|
85
|
+
const indexA = description
|
|
86
|
+
.toLowerCase()
|
|
87
|
+
.indexOf(tokenise(a.entry.name)[0])
|
|
88
|
+
const indexB = description
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
.indexOf(tokenise(b.entry.name)[0])
|
|
91
|
+
return indexA - indexB
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const combinedEmmet = selected.map((s) => s.entry.emmet).join('+')
|
|
95
|
+
const totalScore = selected.reduce((sum, s) => sum + s.score, 0)
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
name: selected.map((s) => s.entry.name).join(' and '),
|
|
99
|
+
emmet: combinedEmmet,
|
|
100
|
+
score: totalScore,
|
|
101
|
+
source: 'intent-table'
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (bestSingle && bestSingle.score >= 18) {
|
|
107
|
+
if (!isStitchRequested || bestSingle.score >= 25) {
|
|
108
|
+
return bestSingle
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
function searchVectorLayer(description: string): IntentMatch | null {
|
|
116
|
+
const results = searchIndex(description)
|
|
117
|
+
if (results.length === 0) return null
|
|
118
|
+
|
|
119
|
+
const isStitchRequested =
|
|
120
|
+
/\b(and|plus|\+)\b/i.test(description) || description.includes(',')
|
|
121
|
+
|
|
122
|
+
if (isStitchRequested && results.length >= 2) {
|
|
123
|
+
const top = results[0]
|
|
124
|
+
const second = results[1]
|
|
125
|
+
if (top.name !== second.name && top.score >= 2.0 && second.score >= 2.0) {
|
|
126
|
+
const indexA = description.toLowerCase().indexOf(top.name.toLowerCase())
|
|
127
|
+
const indexB = description.toLowerCase().indexOf(second.name.toLowerCase())
|
|
128
|
+
const firstItem = indexA < indexB ? top : second
|
|
129
|
+
const secondItem = indexA < indexB ? second : top
|
|
130
|
+
return {
|
|
131
|
+
name: `${firstItem.name} and ${secondItem.name}`,
|
|
132
|
+
emmet: `${firstItem.emmet}+${secondItem.emmet}`,
|
|
133
|
+
score: (top.score + second.score) / 2,
|
|
134
|
+
source: 'vector-index'
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const top = results[0]
|
|
140
|
+
return {
|
|
141
|
+
name: top.name,
|
|
142
|
+
emmet: top.emmet,
|
|
143
|
+
score: top.score,
|
|
144
|
+
source: 'vector-index'
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
export async function howToBuild(description: string): Promise<MCPResponse> {
|
|
150
|
+
let cleanDesc = description.trim()
|
|
151
|
+
if (cleanDesc.startsWith('{') && cleanDesc.endsWith('}')) {
|
|
152
|
+
try {
|
|
153
|
+
const parsed = JSON.parse(cleanDesc)
|
|
154
|
+
if (parsed && typeof parsed.description === 'string') {
|
|
155
|
+
cleanDesc = parsed.description
|
|
156
|
+
} else if (parsed && typeof parsed.text === 'string') {
|
|
157
|
+
cleanDesc = parsed.text
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
// Ignore parsing errors, keep original string
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Layer 1 - fast, deterministic, hand-crafted intent table
|
|
165
|
+
let match = searchIntentTable(cleanDesc)
|
|
166
|
+
|
|
167
|
+
// Layer 2 - vector index fallback for novel / unknown queries
|
|
168
|
+
if (!match) {
|
|
169
|
+
match = searchVectorLayer(cleanDesc)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!match) {
|
|
173
|
+
return {
|
|
174
|
+
content: [
|
|
175
|
+
{
|
|
176
|
+
type: 'text',
|
|
177
|
+
text: JSON.stringify({
|
|
178
|
+
emmet: '',
|
|
179
|
+
html: '',
|
|
180
|
+
components_used: [],
|
|
181
|
+
confidence: 0,
|
|
182
|
+
tokens: 0,
|
|
183
|
+
tokens_used: 15,
|
|
184
|
+
source: 'no-match',
|
|
185
|
+
suggestion:
|
|
186
|
+
'Call list_components() to see all available components, or try a different description.'
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
]
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Apply dynamic interpolation to customize labels, types, and colors on the fly
|
|
194
|
+
const emmet = interpolateEmmet(match.emmet, cleanDesc)
|
|
195
|
+
const components_used = extractComponents(emmet)
|
|
196
|
+
|
|
197
|
+
const confidence = Math.min(1, match.score / 40)
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
content: [
|
|
201
|
+
{
|
|
202
|
+
type: 'text',
|
|
203
|
+
text: JSON.stringify({
|
|
204
|
+
emmet,
|
|
205
|
+
html: expandEmmet(emmet),
|
|
206
|
+
components_used,
|
|
207
|
+
confidence: Number(confidence.toFixed(2)),
|
|
208
|
+
tokens: getTokenCount(emmet),
|
|
209
|
+
tokens_used: 15,
|
|
210
|
+
source: match.source // tells caller which layer matched
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
]
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { manifests } from '../manifests/index.js'
|
|
2
|
+
|
|
3
|
+
import type { MCPResponse } from '../types.js'
|
|
4
|
+
|
|
5
|
+
export function listComponents(): MCPResponse {
|
|
6
|
+
const components = Object.keys(manifests).sort()
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
content: [
|
|
10
|
+
{
|
|
11
|
+
type: 'text',
|
|
12
|
+
text: JSON.stringify({
|
|
13
|
+
components,
|
|
14
|
+
count: components.length,
|
|
15
|
+
tokens_used: components.length
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
2
|
+
import { chromium, webkit } from 'playwright'
|
|
3
|
+
import { readFileSync, existsSync } from 'fs'
|
|
4
|
+
import path from 'path'
|
|
5
|
+
import { fileURLToPath } from 'url'
|
|
6
|
+
import { expandEmmet } from '../utils/emmet-helpers.js'
|
|
7
|
+
import type { MCPResponse } from '../types.js'
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
10
|
+
const __dirname = path.dirname(__filename)
|
|
11
|
+
|
|
12
|
+
export type PreviewOptions = {
|
|
13
|
+
width?: number
|
|
14
|
+
theme?: string
|
|
15
|
+
scale?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type PreviewArgs = {
|
|
19
|
+
input: string
|
|
20
|
+
options?: PreviewOptions
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function preview(args: PreviewArgs): Promise<MCPResponse> {
|
|
24
|
+
const start = Date.now()
|
|
25
|
+
const { input, options = {} } = args
|
|
26
|
+
const { width = 400, scale = 2, theme } = options
|
|
27
|
+
|
|
28
|
+
// Sanitize width and scale to prevent emulation protocol crashes on non-positive values
|
|
29
|
+
const sanitizedWidth = typeof width === 'number' && width > 0 ? width : 400
|
|
30
|
+
const sanitizedScale = typeof scale === 'number' && scale > 0 ? scale : 2
|
|
31
|
+
|
|
32
|
+
// Expand Emmet to HTML if it doesn't look like standard HTML
|
|
33
|
+
const html = input.trim().startsWith('<') ? input : expandEmmet(input)
|
|
34
|
+
|
|
35
|
+
// Resolve CSS path by searching upwards for workspace root directory
|
|
36
|
+
let currentDir = __dirname
|
|
37
|
+
let rootDir = ''
|
|
38
|
+
for (let i = 0; i < 6; i++) {
|
|
39
|
+
if (
|
|
40
|
+
existsSync(path.join(currentDir, 'pnpm-workspace.yaml')) ||
|
|
41
|
+
(existsSync(path.join(currentDir, 'package.json')) &&
|
|
42
|
+
existsSync(path.join(currentDir, 'packages')))
|
|
43
|
+
) {
|
|
44
|
+
rootDir = currentDir
|
|
45
|
+
break
|
|
46
|
+
}
|
|
47
|
+
const parent = path.dirname(currentDir)
|
|
48
|
+
if (parent === currentDir) break
|
|
49
|
+
currentDir = parent
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let cssPath = ''
|
|
53
|
+
if (rootDir) {
|
|
54
|
+
cssPath = path.join(rootDir, 'packages/core/dist/ignix-lite.min.css')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Fallback options
|
|
58
|
+
if (!cssPath || !existsSync(cssPath)) {
|
|
59
|
+
cssPath = path.resolve(
|
|
60
|
+
process.cwd(),
|
|
61
|
+
'packages/core/dist/ignix-lite.min.css'
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
if (!existsSync(cssPath)) {
|
|
65
|
+
cssPath = path.resolve(process.cwd(), '../core/dist/ignix-lite.min.css')
|
|
66
|
+
}
|
|
67
|
+
if (!existsSync(cssPath)) {
|
|
68
|
+
cssPath = path.resolve(__dirname, '../../../core/dist/ignix-lite.min.css')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let cssContent = ''
|
|
72
|
+
if (existsSync(cssPath)) {
|
|
73
|
+
cssContent = readFileSync(cssPath, 'utf8')
|
|
74
|
+
} else {
|
|
75
|
+
console.warn(`[preview] CSS file not found at: ${cssPath}`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let browser
|
|
79
|
+
try {
|
|
80
|
+
browser = await webkit.launch({
|
|
81
|
+
headless: true
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const context = await browser.newContext({
|
|
85
|
+
javaScriptEnabled: false,
|
|
86
|
+
viewport: {
|
|
87
|
+
width: sanitizedWidth,
|
|
88
|
+
height: 600
|
|
89
|
+
},
|
|
90
|
+
deviceScaleFactor: sanitizedScale,
|
|
91
|
+
colorScheme: (theme === 'dark' || theme === 'light') ? theme : undefined
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const page = await context.newPage()
|
|
95
|
+
|
|
96
|
+
// Route interception (SSRF Mitigation & Sandbox Hardening)
|
|
97
|
+
// Only allow data URIs and about:blank. Block all outgoing HTTP/HTTPS traffic.
|
|
98
|
+
await page.route('**/*', (route) => {
|
|
99
|
+
const url = route.request().url()
|
|
100
|
+
if (url.startsWith('data:') || url === 'about:blank') {
|
|
101
|
+
route.continue()
|
|
102
|
+
} else {
|
|
103
|
+
route.abort()
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// Set page content with Ignix-Lite styles injected
|
|
108
|
+
const pageContent = `
|
|
109
|
+
<!DOCTYPE html>
|
|
110
|
+
<html>
|
|
111
|
+
<head>
|
|
112
|
+
<meta charset="utf-8">
|
|
113
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:;">
|
|
114
|
+
<style>
|
|
115
|
+
body {
|
|
116
|
+
margin: 0;
|
|
117
|
+
padding: 16px;
|
|
118
|
+
background-color: var(--ix-surface, #f9fafb);
|
|
119
|
+
font-family: var(--ix-font, sans-serif);
|
|
120
|
+
}
|
|
121
|
+
#preview-container {
|
|
122
|
+
display: flex;
|
|
123
|
+
justify-content: center;
|
|
124
|
+
align-items: center;
|
|
125
|
+
flex-wrap: wrap;
|
|
126
|
+
gap: 16px;
|
|
127
|
+
width: 100%;
|
|
128
|
+
box-sizing: border-box;
|
|
129
|
+
}
|
|
130
|
+
${cssContent}
|
|
131
|
+
</style>
|
|
132
|
+
</head>
|
|
133
|
+
<body>
|
|
134
|
+
<div id="preview-container">${html}</div>
|
|
135
|
+
</body>
|
|
136
|
+
</html>
|
|
137
|
+
`
|
|
138
|
+
await page.setContent(pageContent, { waitUntil: 'load' })
|
|
139
|
+
|
|
140
|
+
// Take screenshot of container
|
|
141
|
+
const container = page.locator('#preview-container')
|
|
142
|
+
let base64Png = ''
|
|
143
|
+
try {
|
|
144
|
+
const buffer = await container.screenshot()
|
|
145
|
+
base64Png = buffer.toString('base64')
|
|
146
|
+
} catch (error) {
|
|
147
|
+
// Fallback to taking a full page screenshot if container has 0 size or fails
|
|
148
|
+
const buffer = await page.screenshot()
|
|
149
|
+
base64Png = buffer.toString('base64')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const duration = Date.now() - start
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
content: [
|
|
156
|
+
{
|
|
157
|
+
type: 'text',
|
|
158
|
+
text: JSON.stringify({
|
|
159
|
+
png: `data:image/png;base64,${base64Png}`,
|
|
160
|
+
width: sanitizedWidth,
|
|
161
|
+
render_ms: duration,
|
|
162
|
+
tokens_used: 5
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
169
|
+
console.error('[preview] Render failed:', error)
|
|
170
|
+
return {
|
|
171
|
+
content: [
|
|
172
|
+
{
|
|
173
|
+
type: 'text',
|
|
174
|
+
text: JSON.stringify({
|
|
175
|
+
error: `Preview render failed: ${errorMessage}`,
|
|
176
|
+
tokens_used: 5
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
]
|
|
180
|
+
}
|
|
181
|
+
} finally {
|
|
182
|
+
if (browser) {
|
|
183
|
+
await browser.close()
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import path from 'path'
|
|
2
|
+
import { readFileSync, existsSync } from 'fs'
|
|
3
|
+
import { fileURLToPath } from 'url'
|
|
4
|
+
import { embedText } from './embedder.js'
|
|
5
|
+
import { cosineSimilarity } from '../utils/cosine.js'
|
|
6
|
+
import { STOP_WORDS } from '../utils/intent-helpers.js'
|
|
7
|
+
|
|
8
|
+
type IndexItem = {
|
|
9
|
+
name: string
|
|
10
|
+
emmet: string
|
|
11
|
+
searchable: string
|
|
12
|
+
embedding: number[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
16
|
+
const __dirname = path.dirname(__filename)
|
|
17
|
+
|
|
18
|
+
let _index: IndexItem[] | null = null
|
|
19
|
+
|
|
20
|
+
function loadIndex(): IndexItem[] {
|
|
21
|
+
if (_index) return _index
|
|
22
|
+
const paths = [
|
|
23
|
+
path.resolve(__dirname, '../../dist/vector-index.json'),
|
|
24
|
+
path.resolve(__dirname, './vector-index.json'),
|
|
25
|
+
path.resolve(__dirname, '../vector-index.json')
|
|
26
|
+
]
|
|
27
|
+
let indexPath = paths[0]
|
|
28
|
+
for (const p of paths) {
|
|
29
|
+
if (existsSync(p)) {
|
|
30
|
+
indexPath = p
|
|
31
|
+
break
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!existsSync(indexPath)) {
|
|
35
|
+
console.warn(
|
|
36
|
+
`[search-index] dist/vector-index.json not found at: ${indexPath} - run pnpm build:index`
|
|
37
|
+
)
|
|
38
|
+
_index = []
|
|
39
|
+
return _index
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
_index = JSON.parse(readFileSync(indexPath, 'utf8'))
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error(`[search-index] Failed to load index from ${indexPath}:`, err)
|
|
45
|
+
_index = []
|
|
46
|
+
}
|
|
47
|
+
return _index!
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function searchIndex(description: string) {
|
|
51
|
+
const index = loadIndex()
|
|
52
|
+
const queryEmbedding = embedText(description)
|
|
53
|
+
|
|
54
|
+
const words = description.toLowerCase().split(/\s+/)
|
|
55
|
+
const ranked = index
|
|
56
|
+
.map((item) => {
|
|
57
|
+
const similarity = cosineSimilarity(queryEmbedding, item.embedding)
|
|
58
|
+
let boost = 0
|
|
59
|
+
const searchable = item.searchable.toLowerCase()
|
|
60
|
+
|
|
61
|
+
words.forEach((word) => {
|
|
62
|
+
if (STOP_WORDS.has(word) || word.length < 3) {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
if (item.name.toLowerCase() === word) {
|
|
66
|
+
boost += 2
|
|
67
|
+
} else if (item.name.toLowerCase().includes(word)) {
|
|
68
|
+
boost += 1
|
|
69
|
+
}
|
|
70
|
+
if (searchable.includes(word)) {
|
|
71
|
+
boost += 0.3
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
return {
|
|
75
|
+
...item,
|
|
76
|
+
score: similarity + boost
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
.sort((a, b) => b.score - a.score)
|
|
80
|
+
return ranked.slice(0, 3)
|
|
81
|
+
}
|
|
82
|
+
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export interface ColorEntry {
|
|
2
|
+
primary: string
|
|
3
|
+
contrast: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const PALETTE: Record<string, ColorEntry> = {
|
|
7
|
+
red: { primary: '#ef4444', contrast: '#ffffff' },
|
|
8
|
+
orange: { primary: '#f97316', contrast: '#ffffff' },
|
|
9
|
+
amber: { primary: '#f59e0b', contrast: '#111827' },
|
|
10
|
+
yellow: { primary: '#eab308', contrast: '#111827' },
|
|
11
|
+
lime: { primary: '#84cc16', contrast: '#111827' },
|
|
12
|
+
green: { primary: '#22c55e', contrast: '#ffffff' },
|
|
13
|
+
emerald: { primary: '#10b981', contrast: '#ffffff' },
|
|
14
|
+
teal: { primary: '#14b8a6', contrast: '#ffffff' },
|
|
15
|
+
cyan: { primary: '#06b6d4', contrast: '#111827' },
|
|
16
|
+
sky: { primary: '#0ea5e9', contrast: '#111827' },
|
|
17
|
+
blue: { primary: '#3b82f6', contrast: '#ffffff' },
|
|
18
|
+
indigo: { primary: '#6366f1', contrast: '#ffffff' },
|
|
19
|
+
violet: { primary: '#8b5cf6', contrast: '#ffffff' },
|
|
20
|
+
purple: { primary: '#a855f7', contrast: '#ffffff' },
|
|
21
|
+
fuchsia: { primary: '#d946ef', contrast: '#ffffff' },
|
|
22
|
+
pink: { primary: '#ec4899', contrast: '#ffffff' },
|
|
23
|
+
rose: { primary: '#f43f5e', contrast: '#ffffff' },
|
|
24
|
+
cyberpunk: { primary: '#ec4899', contrast: '#ffffff' },
|
|
25
|
+
neon: { primary: '#d946ef', contrast: '#ffffff' },
|
|
26
|
+
eco: { primary: '#10b981', contrast: '#ffffff' },
|
|
27
|
+
forest: { primary: '#0f766e', contrast: '#ffffff' },
|
|
28
|
+
coffee: { primary: '#78350f', contrast: '#ffffff' },
|
|
29
|
+
ocean: { primary: '#0284c7', contrast: '#ffffff' },
|
|
30
|
+
sunset: { primary: '#f97316', contrast: '#ffffff' },
|
|
31
|
+
midnight: { primary: '#3730a3', contrast: '#ffffff' },
|
|
32
|
+
lavender: { primary: '#8b5cf6', contrast: '#ffffff' },
|
|
33
|
+
coral: { primary: '#f43f5e', contrast: '#ffffff' },
|
|
34
|
+
slate: { primary: '#64748b', contrast: '#ffffff' },
|
|
35
|
+
gold: { primary: '#d97706', contrast: '#111827' },
|
|
36
|
+
silver: { primary: '#94a3b8', contrast: '#111827' }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function expandHex(hex: string): string {
|
|
40
|
+
const h = hex.replace('#', '')
|
|
41
|
+
return h.length === 3 ? '#' + h[0] + h[0] + h[1] + h[1] + h[2] + h[2] : hex
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function contrastFor(hex6: string): string {
|
|
45
|
+
const h = hex6.replace('#', '')
|
|
46
|
+
const r = parseInt(h.substring(0, 2), 16)
|
|
47
|
+
const g = parseInt(h.substring(2, 4), 16)
|
|
48
|
+
const b = parseInt(h.substring(4, 6), 16)
|
|
49
|
+
return (r * 299 + g * 587 + b * 114) / 1000 > 165 ? '#111827' : '#ffffff'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolveColor(query: string): ColorEntry {
|
|
53
|
+
const hexMatch = query.match(/#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/)
|
|
54
|
+
if (hexMatch) {
|
|
55
|
+
const primary = expandHex(hexMatch[0])
|
|
56
|
+
return { primary, contrast: contrastFor(primary) }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const sorted = Object.keys(PALETTE).sort((a, b) => b.length - a.length)
|
|
60
|
+
for (const name of sorted) {
|
|
61
|
+
if (query.includes(name)) return PALETTE[name]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { primary: '#6366f1', contrast: '#ffffff' }
|
|
65
|
+
}
|