harper-knowledge 0.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/LICENSE +21 -0
- package/README.md +276 -0
- package/config.yaml +17 -0
- package/dist/core/embeddings.d.ts +29 -0
- package/dist/core/embeddings.js +199 -0
- package/dist/core/entries.d.ts +85 -0
- package/dist/core/entries.js +235 -0
- package/dist/core/history.d.ts +30 -0
- package/dist/core/history.js +119 -0
- package/dist/core/search.d.ts +23 -0
- package/dist/core/search.js +306 -0
- package/dist/core/tags.d.ts +32 -0
- package/dist/core/tags.js +76 -0
- package/dist/core/triage.d.ts +55 -0
- package/dist/core/triage.js +126 -0
- package/dist/http-utils.d.ts +37 -0
- package/dist/http-utils.js +132 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +76 -0
- package/dist/mcp/server.d.ts +24 -0
- package/dist/mcp/server.js +124 -0
- package/dist/mcp/tools.d.ts +13 -0
- package/dist/mcp/tools.js +497 -0
- package/dist/oauth/authorize.d.ts +27 -0
- package/dist/oauth/authorize.js +438 -0
- package/dist/oauth/github.d.ts +28 -0
- package/dist/oauth/github.js +62 -0
- package/dist/oauth/keys.d.ts +33 -0
- package/dist/oauth/keys.js +100 -0
- package/dist/oauth/metadata.d.ts +21 -0
- package/dist/oauth/metadata.js +55 -0
- package/dist/oauth/middleware.d.ts +22 -0
- package/dist/oauth/middleware.js +64 -0
- package/dist/oauth/register.d.ts +14 -0
- package/dist/oauth/register.js +83 -0
- package/dist/oauth/token.d.ts +15 -0
- package/dist/oauth/token.js +178 -0
- package/dist/oauth/validate.d.ts +30 -0
- package/dist/oauth/validate.js +52 -0
- package/dist/resources/HistoryResource.d.ts +38 -0
- package/dist/resources/HistoryResource.js +38 -0
- package/dist/resources/KnowledgeEntryResource.d.ts +64 -0
- package/dist/resources/KnowledgeEntryResource.js +157 -0
- package/dist/resources/QueryLogResource.d.ts +20 -0
- package/dist/resources/QueryLogResource.js +57 -0
- package/dist/resources/ServiceKeyResource.d.ts +51 -0
- package/dist/resources/ServiceKeyResource.js +132 -0
- package/dist/resources/TagResource.d.ts +25 -0
- package/dist/resources/TagResource.js +32 -0
- package/dist/resources/TriageResource.d.ts +51 -0
- package/dist/resources/TriageResource.js +107 -0
- package/dist/types.d.ts +317 -0
- package/dist/types.js +7 -0
- package/dist/webhooks/datadog.d.ts +26 -0
- package/dist/webhooks/datadog.js +120 -0
- package/dist/webhooks/github.d.ts +24 -0
- package/dist/webhooks/github.js +167 -0
- package/dist/webhooks/middleware.d.ts +14 -0
- package/dist/webhooks/middleware.js +161 -0
- package/dist/webhooks/types.d.ts +17 -0
- package/dist/webhooks/types.js +4 -0
- package/package.json +72 -0
- package/schema/knowledge.graphql +134 -0
- package/web/index.html +735 -0
- package/web/js/app.js +461 -0
- package/web/js/detail.js +223 -0
- package/web/js/editor.js +303 -0
- package/web/js/search.js +238 -0
- package/web/js/triage.js +305 -0
package/web/js/editor.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harper Knowledge Base — Entry Editor
|
|
3
|
+
*
|
|
4
|
+
* Create and edit knowledge base entries.
|
|
5
|
+
* Handles form rendering, validation, and API submission.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { api, auth, navigate, escapeHtml, showLoginModal } from "./app.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Render the editor page.
|
|
12
|
+
* @param {HTMLElement} container
|
|
13
|
+
* @param {string|null} entryId - Existing entry ID for edit mode, null for create
|
|
14
|
+
*/
|
|
15
|
+
export async function render(container, entryId) {
|
|
16
|
+
const isEdit = !!entryId;
|
|
17
|
+
let existing = null;
|
|
18
|
+
let triageData = null;
|
|
19
|
+
|
|
20
|
+
if (isEdit) {
|
|
21
|
+
container.innerHTML = '<div class="loading">Loading entry...</div>';
|
|
22
|
+
try {
|
|
23
|
+
existing = await api.get(`/Knowledge/${encodeURIComponent(entryId)}`);
|
|
24
|
+
if (!existing || existing.error) {
|
|
25
|
+
container.innerHTML = `<div class="empty-state"><h2>Entry not found</h2><p>Cannot edit: entry ${escapeHtml(entryId)} does not exist.</p></div>`;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
} catch (err) {
|
|
29
|
+
container.innerHTML = `<div class="status-message error">Failed to load entry: ${escapeHtml(err.message)}</div>`;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Check for triage accept data (pre-populate from triage queue)
|
|
35
|
+
if (!isEdit) {
|
|
36
|
+
try {
|
|
37
|
+
const raw = sessionStorage.getItem("triage-accept");
|
|
38
|
+
if (raw) {
|
|
39
|
+
triageData = JSON.parse(raw);
|
|
40
|
+
sessionStorage.removeItem("triage-accept");
|
|
41
|
+
// Pre-populate existing with triage summary
|
|
42
|
+
existing = {
|
|
43
|
+
title: triageData.summary || "",
|
|
44
|
+
content: triageData.summary || "",
|
|
45
|
+
source: triageData.source || "",
|
|
46
|
+
confidence: "ai-generated",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// Ignore parse errors
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
container.innerHTML = renderForm(isEdit, existing, triageData);
|
|
55
|
+
wireEvents(container, isEdit, entryId, triageData);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function renderForm(isEdit, entry, triageData) {
|
|
59
|
+
const e = entry || {};
|
|
60
|
+
|
|
61
|
+
const triageBanner = triageData
|
|
62
|
+
? `<div class="status-message" style="background: var(--amber-bg); color: var(--amber); border: 1px solid var(--amber); margin-bottom: 16px;">
|
|
63
|
+
Creating entry from triage item. Review and adjust the fields below, then submit.
|
|
64
|
+
</div>`
|
|
65
|
+
: "";
|
|
66
|
+
|
|
67
|
+
return `
|
|
68
|
+
<h2 style="margin-bottom: 20px;">${isEdit ? "Edit Entry" : "Add New Entry"}</h2>
|
|
69
|
+
|
|
70
|
+
${triageBanner}
|
|
71
|
+
<div id="editor-status"></div>
|
|
72
|
+
|
|
73
|
+
<div class="form-group">
|
|
74
|
+
<label for="ed-title">Title</label>
|
|
75
|
+
<input type="text" id="ed-title" value="${escapeHtml(e.title || "")}" placeholder="Brief descriptive title">
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<div class="form-group">
|
|
79
|
+
<label for="ed-content">Content</label>
|
|
80
|
+
<textarea id="ed-content" placeholder="Full knowledge entry content (supports plain text; markdown can be added later)">${escapeHtml(e.content || "")}</textarea>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<div class="form-row">
|
|
84
|
+
<div class="form-group">
|
|
85
|
+
<label for="ed-tags">Tags (comma-separated)</label>
|
|
86
|
+
<input type="text" id="ed-tags" value="${escapeHtml((e.tags || []).join(", "))}" placeholder="e.g., performance, clustering, lmdb">
|
|
87
|
+
<div id="ed-tag-chips" class="tags" style="margin-top: 6px;"></div>
|
|
88
|
+
</div>
|
|
89
|
+
<div class="form-group">
|
|
90
|
+
<label for="ed-confidence">Confidence</label>
|
|
91
|
+
<select id="ed-confidence">
|
|
92
|
+
<option value="ai-generated" ${e.confidence === "ai-generated" ? "selected" : ""}>ai-generated</option>
|
|
93
|
+
<option value="reviewed" ${e.confidence === "reviewed" ? "selected" : ""}>reviewed</option>
|
|
94
|
+
<option value="verified" ${e.confidence === "verified" ? "selected" : ""}>verified</option>
|
|
95
|
+
</select>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div class="collapsible-header open" id="applies-toggle">
|
|
100
|
+
<span class="arrow" style="transform: rotate(90deg);">▶</span>
|
|
101
|
+
Applicability
|
|
102
|
+
</div>
|
|
103
|
+
<div class="collapsible-body open" id="applies-panel">
|
|
104
|
+
<div class="form-row" style="margin-bottom: 12px;">
|
|
105
|
+
<div class="form-group">
|
|
106
|
+
<label for="ed-harper">Harper Version (semver range)</label>
|
|
107
|
+
<input type="text" id="ed-harper" value="${escapeHtml(e.appliesTo?.harper || "")}" placeholder="e.g., >=4.6.0">
|
|
108
|
+
</div>
|
|
109
|
+
<div class="form-group">
|
|
110
|
+
<label for="ed-storage">Storage Engine</label>
|
|
111
|
+
<select id="ed-storage">
|
|
112
|
+
<option value="">Any</option>
|
|
113
|
+
<option value="lmdb" ${e.appliesTo?.storageEngine === "lmdb" ? "selected" : ""}>lmdb</option>
|
|
114
|
+
<option value="rocksdb" ${e.appliesTo?.storageEngine === "rocksdb" ? "selected" : ""}>rocksdb</option>
|
|
115
|
+
</select>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
<div class="form-row">
|
|
119
|
+
<div class="form-group">
|
|
120
|
+
<label for="ed-node">Node.js Version</label>
|
|
121
|
+
<input type="text" id="ed-node" value="${escapeHtml(e.appliesTo?.node || "")}" placeholder="e.g., >=22.0.0">
|
|
122
|
+
</div>
|
|
123
|
+
<div class="form-group">
|
|
124
|
+
<label for="ed-platform">Platform</label>
|
|
125
|
+
<select id="ed-platform">
|
|
126
|
+
<option value="">Any</option>
|
|
127
|
+
<option value="linux" ${e.appliesTo?.platform === "linux" ? "selected" : ""}>linux</option>
|
|
128
|
+
<option value="darwin" ${e.appliesTo?.platform === "darwin" ? "selected" : ""}>darwin</option>
|
|
129
|
+
<option value="win32" ${e.appliesTo?.platform === "win32" ? "selected" : ""}>win32</option>
|
|
130
|
+
</select>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<div class="form-row" style="margin-top: 16px;">
|
|
136
|
+
<div class="form-group">
|
|
137
|
+
<label for="ed-source">Source</label>
|
|
138
|
+
<input type="text" id="ed-source" value="${escapeHtml(e.source || "")}" placeholder="e.g., support-ticket, documentation, slack">
|
|
139
|
+
</div>
|
|
140
|
+
<div class="form-group">
|
|
141
|
+
<label for="ed-source-url">Source URL</label>
|
|
142
|
+
<input type="url" id="ed-source-url" value="${escapeHtml(e.sourceUrl || "")}" placeholder="https://...">
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div class="form-group">
|
|
147
|
+
<label for="ed-supersedes">Supersedes Entry ID</label>
|
|
148
|
+
<input type="text" id="ed-supersedes" value="${escapeHtml(e.supersedesId || "")}" placeholder="ID of the entry this supersedes (optional)">
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div style="display: flex; gap: 8px; margin-top: 24px;">
|
|
152
|
+
<button id="ed-submit" class="primary">${isEdit ? "Update Entry" : "Create Entry"}</button>
|
|
153
|
+
<button id="ed-cancel">Cancel</button>
|
|
154
|
+
</div>
|
|
155
|
+
`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function wireEvents(container, isEdit, entryId, triageData) {
|
|
159
|
+
const tagsInput = container.querySelector("#ed-tags");
|
|
160
|
+
const tagChips = container.querySelector("#ed-tag-chips");
|
|
161
|
+
const submitBtn = container.querySelector("#ed-submit");
|
|
162
|
+
const cancelBtn = container.querySelector("#ed-cancel");
|
|
163
|
+
const appliesToggle = container.querySelector("#applies-toggle");
|
|
164
|
+
const appliesPanel = container.querySelector("#applies-panel");
|
|
165
|
+
const statusDiv = container.querySelector("#editor-status");
|
|
166
|
+
|
|
167
|
+
// Tag chips preview
|
|
168
|
+
const updateTagChips = () => {
|
|
169
|
+
const tags = parseTags(tagsInput.value);
|
|
170
|
+
tagChips.innerHTML = tags
|
|
171
|
+
.map((t) => `<span class="tag">${escapeHtml(t)}</span>`)
|
|
172
|
+
.join("");
|
|
173
|
+
};
|
|
174
|
+
tagsInput.addEventListener("input", updateTagChips);
|
|
175
|
+
updateTagChips();
|
|
176
|
+
|
|
177
|
+
// Collapsible applicability
|
|
178
|
+
appliesToggle.addEventListener("click", () => {
|
|
179
|
+
appliesToggle.classList.toggle("open");
|
|
180
|
+
appliesPanel.classList.toggle("open");
|
|
181
|
+
const arrow = appliesToggle.querySelector(".arrow");
|
|
182
|
+
arrow.style.transform = appliesToggle.classList.contains("open")
|
|
183
|
+
? "rotate(90deg)"
|
|
184
|
+
: "";
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Cancel
|
|
188
|
+
cancelBtn.addEventListener("click", () => {
|
|
189
|
+
if (isEdit) {
|
|
190
|
+
navigate("entry", entryId);
|
|
191
|
+
} else {
|
|
192
|
+
navigate("search");
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Submit
|
|
197
|
+
submitBtn.addEventListener("click", async () => {
|
|
198
|
+
if (!auth.authenticated) {
|
|
199
|
+
showLoginModal();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Validate
|
|
204
|
+
const title = container.querySelector("#ed-title").value.trim();
|
|
205
|
+
const content = container.querySelector("#ed-content").value.trim();
|
|
206
|
+
|
|
207
|
+
if (!title) {
|
|
208
|
+
statusDiv.innerHTML =
|
|
209
|
+
'<div class="status-message error">Title is required.</div>';
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (!content) {
|
|
213
|
+
statusDiv.innerHTML =
|
|
214
|
+
'<div class="status-message error">Content is required.</div>';
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Build payload
|
|
219
|
+
const payload = {
|
|
220
|
+
title,
|
|
221
|
+
content,
|
|
222
|
+
tags: parseTags(container.querySelector("#ed-tags").value),
|
|
223
|
+
confidence: container.querySelector("#ed-confidence").value,
|
|
224
|
+
source: container.querySelector("#ed-source").value.trim() || undefined,
|
|
225
|
+
sourceUrl:
|
|
226
|
+
container.querySelector("#ed-source-url").value.trim() || undefined,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// Applicability
|
|
230
|
+
const appliesTo = {};
|
|
231
|
+
const harper = container.querySelector("#ed-harper").value.trim();
|
|
232
|
+
const storage = container.querySelector("#ed-storage").value;
|
|
233
|
+
const node = container.querySelector("#ed-node").value.trim();
|
|
234
|
+
const platform = container.querySelector("#ed-platform").value;
|
|
235
|
+
|
|
236
|
+
if (harper) appliesTo.harper = harper;
|
|
237
|
+
if (storage) appliesTo.storageEngine = storage;
|
|
238
|
+
if (node) appliesTo.node = node;
|
|
239
|
+
if (platform) appliesTo.platform = platform;
|
|
240
|
+
|
|
241
|
+
if (Object.keys(appliesTo).length > 0) {
|
|
242
|
+
payload.appliesTo = appliesTo;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Supersedes
|
|
246
|
+
const supersedesId = container.querySelector("#ed-supersedes").value.trim();
|
|
247
|
+
if (supersedesId) {
|
|
248
|
+
payload.supersedesId = supersedesId;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Submit
|
|
252
|
+
submitBtn.disabled = true;
|
|
253
|
+
submitBtn.textContent = isEdit ? "Updating..." : "Creating...";
|
|
254
|
+
statusDiv.innerHTML = "";
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
let result;
|
|
258
|
+
if (isEdit) {
|
|
259
|
+
result = await api.put(
|
|
260
|
+
`/Knowledge/${encodeURIComponent(entryId)}`,
|
|
261
|
+
payload,
|
|
262
|
+
);
|
|
263
|
+
} else {
|
|
264
|
+
result = await api.post("/Knowledge/", payload);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const newId = result?.id || entryId;
|
|
268
|
+
|
|
269
|
+
// If this was a triage accept, update the triage item
|
|
270
|
+
if (triageData?.triageId && !isEdit) {
|
|
271
|
+
try {
|
|
272
|
+
await api.put(`/Triage/${encodeURIComponent(triageData.triageId)}`, {
|
|
273
|
+
action: "accepted",
|
|
274
|
+
linkedEntryId: newId,
|
|
275
|
+
});
|
|
276
|
+
} catch (triageErr) {
|
|
277
|
+
console.error("Failed to update triage item:", triageErr);
|
|
278
|
+
// Non-fatal: the entry was still created
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
statusDiv.innerHTML = `<div class="status-message success">${isEdit ? "Entry updated" : "Entry created"} successfully.</div>`;
|
|
283
|
+
|
|
284
|
+
// Navigate to the entry after a short delay
|
|
285
|
+
setTimeout(() => {
|
|
286
|
+
navigate("entry", newId);
|
|
287
|
+
}, 800);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
console.error("Save error:", err);
|
|
290
|
+
statusDiv.innerHTML = `<div class="status-message error">Failed to save: ${escapeHtml(err.message)}</div>`;
|
|
291
|
+
submitBtn.disabled = false;
|
|
292
|
+
submitBtn.textContent = isEdit ? "Update Entry" : "Create Entry";
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function parseTags(input) {
|
|
298
|
+
if (!input) return [];
|
|
299
|
+
return input
|
|
300
|
+
.split(",")
|
|
301
|
+
.map((t) => t.trim())
|
|
302
|
+
.filter((t) => t.length > 0);
|
|
303
|
+
}
|
package/web/js/search.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harper Knowledge Base — Search & Browse Page
|
|
3
|
+
*
|
|
4
|
+
* Text search with tag filters, applicability context,
|
|
5
|
+
* and result display.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { api, escapeHtml, confidenceBadge, tagChips, truncate } from "./app.js";
|
|
9
|
+
|
|
10
|
+
let allTags = [];
|
|
11
|
+
let activeTags = [];
|
|
12
|
+
let lastResults = [];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Render the search page.
|
|
16
|
+
* @param {HTMLElement} container
|
|
17
|
+
* @param {boolean} browseMode - If true, show all entries instead of search
|
|
18
|
+
*/
|
|
19
|
+
export async function render(container, browseMode = false) {
|
|
20
|
+
container.innerHTML = `
|
|
21
|
+
<div class="search-bar">
|
|
22
|
+
<input type="text" id="search-input" placeholder="Search the knowledge base..." autofocus>
|
|
23
|
+
<button id="search-btn" class="primary">Search</button>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<div id="tag-filter" class="tags" style="margin-bottom: 12px;"></div>
|
|
27
|
+
|
|
28
|
+
<div class="collapsible-header" id="context-toggle">
|
|
29
|
+
<span class="arrow">▶</span>
|
|
30
|
+
Applicability Context
|
|
31
|
+
</div>
|
|
32
|
+
<div class="collapsible-body" id="context-panel">
|
|
33
|
+
<div class="form-row" style="margin-bottom: 12px;">
|
|
34
|
+
<div class="form-group">
|
|
35
|
+
<label for="ctx-harper">Harper Version</label>
|
|
36
|
+
<input type="text" id="ctx-harper" placeholder="e.g., 4.6.0">
|
|
37
|
+
</div>
|
|
38
|
+
<div class="form-group">
|
|
39
|
+
<label for="ctx-storage">Storage Engine</label>
|
|
40
|
+
<select id="ctx-storage">
|
|
41
|
+
<option value="">Any</option>
|
|
42
|
+
<option value="lmdb">lmdb</option>
|
|
43
|
+
<option value="rocksdb">rocksdb</option>
|
|
44
|
+
</select>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="form-row">
|
|
48
|
+
<div class="form-group">
|
|
49
|
+
<label for="ctx-node">Node.js Version</label>
|
|
50
|
+
<input type="text" id="ctx-node" placeholder="e.g., 22.0.0">
|
|
51
|
+
</div>
|
|
52
|
+
<div class="form-group">
|
|
53
|
+
<label for="ctx-platform">Platform</label>
|
|
54
|
+
<select id="ctx-platform">
|
|
55
|
+
<option value="">Any</option>
|
|
56
|
+
<option value="linux">linux</option>
|
|
57
|
+
<option value="darwin">darwin</option>
|
|
58
|
+
<option value="win32">win32</option>
|
|
59
|
+
</select>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div id="results"></div>
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
// Load tags
|
|
68
|
+
await loadTags(container);
|
|
69
|
+
|
|
70
|
+
// Wire up events
|
|
71
|
+
const searchInput = container.querySelector("#search-input");
|
|
72
|
+
const searchBtn = container.querySelector("#search-btn");
|
|
73
|
+
const contextToggle = container.querySelector("#context-toggle");
|
|
74
|
+
const contextPanel = container.querySelector("#context-panel");
|
|
75
|
+
|
|
76
|
+
searchBtn.addEventListener("click", () => doSearch(container));
|
|
77
|
+
searchInput.addEventListener("keydown", (e) => {
|
|
78
|
+
if (e.key === "Enter") doSearch(container);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
contextToggle.addEventListener("click", () => {
|
|
82
|
+
contextToggle.classList.toggle("open");
|
|
83
|
+
contextPanel.classList.toggle("open");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// If browse mode, search with empty query to list entries
|
|
87
|
+
if (browseMode) {
|
|
88
|
+
searchInput.placeholder = "Filter entries...";
|
|
89
|
+
searchInput.value = "";
|
|
90
|
+
doSearch(container, true);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function loadTags(container) {
|
|
95
|
+
try {
|
|
96
|
+
const data = await api.get("/KnowledgeTag/");
|
|
97
|
+
allTags = Array.isArray(data) ? data : [];
|
|
98
|
+
} catch {
|
|
99
|
+
allTags = [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const tagFilter = container.querySelector("#tag-filter");
|
|
103
|
+
if (!tagFilter) return;
|
|
104
|
+
|
|
105
|
+
if (allTags.length === 0) {
|
|
106
|
+
tagFilter.innerHTML = "";
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
tagFilter.innerHTML = allTags
|
|
111
|
+
.map(
|
|
112
|
+
(t) =>
|
|
113
|
+
`<span class="tag" data-tag="${escapeHtml(t.id)}" title="${escapeHtml(t.description || "")}">${escapeHtml(t.id)} <span style="opacity:0.5;">${t.entryCount || 0}</span></span>`,
|
|
114
|
+
)
|
|
115
|
+
.join("");
|
|
116
|
+
|
|
117
|
+
tagFilter.addEventListener("click", (e) => {
|
|
118
|
+
const tagEl = e.target.closest(".tag");
|
|
119
|
+
if (!tagEl) return;
|
|
120
|
+
const tagName = tagEl.dataset.tag;
|
|
121
|
+
|
|
122
|
+
if (activeTags.includes(tagName)) {
|
|
123
|
+
activeTags = activeTags.filter((t) => t !== tagName);
|
|
124
|
+
tagEl.classList.remove("active");
|
|
125
|
+
} else {
|
|
126
|
+
activeTags.push(tagName);
|
|
127
|
+
tagEl.classList.add("active");
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function doSearch(container, browseMode = false) {
|
|
133
|
+
const resultsDiv = container.querySelector("#results");
|
|
134
|
+
const query = container.querySelector("#search-input")?.value?.trim();
|
|
135
|
+
|
|
136
|
+
if (!query && !browseMode && activeTags.length === 0) {
|
|
137
|
+
resultsDiv.innerHTML = `<div class="empty-state"><h2>Enter a search query</h2><p>Type something to search the knowledge base, or switch to Browse to see all entries.</p></div>`;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
resultsDiv.innerHTML = '<div class="loading">Searching...</div>';
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
// Build query params
|
|
145
|
+
const params = new URLSearchParams();
|
|
146
|
+
if (query) params.set("query", query);
|
|
147
|
+
if (activeTags.length > 0) params.set("tags", activeTags.join(","));
|
|
148
|
+
|
|
149
|
+
// Applicability context
|
|
150
|
+
const context = buildContext(container);
|
|
151
|
+
if (Object.keys(context).length > 0) {
|
|
152
|
+
params.set("context", JSON.stringify(context));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
params.set("limit", "30");
|
|
156
|
+
|
|
157
|
+
const searchQuery = params.toString();
|
|
158
|
+
|
|
159
|
+
// If no query and browse mode, we still need a query param for the API
|
|
160
|
+
// The API requires a query, so for browse mode use a wildcard or similar
|
|
161
|
+
let data;
|
|
162
|
+
if (!query && browseMode) {
|
|
163
|
+
// For browsing without search, use a broad query
|
|
164
|
+
params.set("query", "*");
|
|
165
|
+
data = await api.get("/Knowledge/?" + params.toString());
|
|
166
|
+
} else {
|
|
167
|
+
data = await api.get("/Knowledge/?" + searchQuery);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
lastResults = Array.isArray(data) ? data : [];
|
|
171
|
+
|
|
172
|
+
if (lastResults.length === 0) {
|
|
173
|
+
resultsDiv.innerHTML = `<div class="empty-state"><h2>No results found</h2><p>Try adjusting your search query or filters.</p></div>`;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
resultsDiv.innerHTML = `
|
|
178
|
+
<p style="color: var(--text-dim); font-size: 12px; margin-bottom: 12px;">${lastResults.length} result${lastResults.length === 1 ? "" : "s"}</p>
|
|
179
|
+
${lastResults.map(renderResultCard).join("")}
|
|
180
|
+
`;
|
|
181
|
+
} catch (err) {
|
|
182
|
+
console.error("Search error:", err);
|
|
183
|
+
resultsDiv.innerHTML = `<div class="status-message error">Search failed: ${escapeHtml(err.message)}</div>`;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function buildContext(container) {
|
|
188
|
+
const context = {};
|
|
189
|
+
const harper = container.querySelector("#ctx-harper")?.value?.trim();
|
|
190
|
+
const storage = container.querySelector("#ctx-storage")?.value;
|
|
191
|
+
const node = container.querySelector("#ctx-node")?.value?.trim();
|
|
192
|
+
const platform = container.querySelector("#ctx-platform")?.value;
|
|
193
|
+
|
|
194
|
+
if (harper) context.harper = harper;
|
|
195
|
+
if (storage) context.storageEngine = storage;
|
|
196
|
+
if (node) context.node = node;
|
|
197
|
+
if (platform) context.platform = platform;
|
|
198
|
+
|
|
199
|
+
return context;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function renderResultCard(entry) {
|
|
203
|
+
const deprecatedClass = entry.deprecated ? " deprecated" : "";
|
|
204
|
+
const snippet = truncate(entry.content, 180);
|
|
205
|
+
const scoreStr =
|
|
206
|
+
entry.score != null
|
|
207
|
+
? `<span class="score">${entry.score.toFixed(3)}</span>`
|
|
208
|
+
: "";
|
|
209
|
+
const matchStr = entry.matchType
|
|
210
|
+
? `<span class="score">${escapeHtml(entry.matchType)}</span>`
|
|
211
|
+
: "";
|
|
212
|
+
|
|
213
|
+
return `
|
|
214
|
+
<div class="card${deprecatedClass}">
|
|
215
|
+
<div class="card-title">
|
|
216
|
+
<a href="#entry/${escapeHtml(entry.id)}">${escapeHtml(entry.title)}</a>
|
|
217
|
+
</div>
|
|
218
|
+
<div class="card-snippet">${escapeHtml(snippet)}</div>
|
|
219
|
+
<div class="card-meta">
|
|
220
|
+
${confidenceBadge(entry.confidence)}
|
|
221
|
+
${entry.tags?.length ? '<span class="sep">|</span>' + tagChips(entry.tags, activeTags) : ""}
|
|
222
|
+
${entry.appliesTo ? renderAppliesTo(entry.appliesTo) : ""}
|
|
223
|
+
${scoreStr || matchStr ? '<span class="sep">|</span>' + scoreStr + " " + matchStr : ""}
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function renderAppliesTo(appliesTo) {
|
|
230
|
+
if (!appliesTo) return "";
|
|
231
|
+
const parts = [];
|
|
232
|
+
if (appliesTo.harper) parts.push(`harper ${escapeHtml(appliesTo.harper)}`);
|
|
233
|
+
if (appliesTo.storageEngine) parts.push(escapeHtml(appliesTo.storageEngine));
|
|
234
|
+
if (appliesTo.node) parts.push(`node ${escapeHtml(appliesTo.node)}`);
|
|
235
|
+
if (appliesTo.platform) parts.push(escapeHtml(appliesTo.platform));
|
|
236
|
+
if (parts.length === 0) return "";
|
|
237
|
+
return `<span class="sep">|</span><span class="score">${parts.join(", ")}</span>`;
|
|
238
|
+
}
|