@cloudgrid-io/mcp 0.14.0 → 0.16.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 +1 -1
- package/src/corpus/capability-map.md +44 -15
- package/src/corpus/install.md +27 -0
- package/src/corpus/templates/semantic-search/AGENTS.md +216 -0
- package/src/corpus/templates/semantic-search/README.md +75 -0
- package/src/corpus/templates/semantic-search/cloudgrid.yaml +219 -0
- package/src/corpus/templates/semantic-search/index.md +108 -0
- package/src/corpus/templates/semantic-search/services/backend/requirements.txt +7 -0
- package/src/corpus/templates/semantic-search/services/backend/src/app/__init__.py +0 -0
- package/src/corpus/templates/semantic-search/services/backend/src/app/answer.py +90 -0
- package/src/corpus/templates/semantic-search/services/backend/src/app/auth.py +65 -0
- package/src/corpus/templates/semantic-search/services/backend/src/app/config.py +110 -0
- package/src/corpus/templates/semantic-search/services/backend/src/app/db.py +89 -0
- package/src/corpus/templates/semantic-search/services/backend/src/app/embeddings.py +44 -0
- package/src/corpus/templates/semantic-search/services/backend/src/app/indexing.py +223 -0
- package/src/corpus/templates/semantic-search/services/backend/src/app/main.py +259 -0
- package/src/corpus/templates/semantic-search/services/backend/src/app/search.py +222 -0
- package/src/corpus/templates/semantic-search/services/backend/src/app/source.py +259 -0
- package/src/corpus/templates/semantic-search/services/backend/src/main.py +12 -0
- package/src/corpus/templates/semantic-search/services/refresh/README.md +42 -0
- package/src/corpus/templates/semantic-search/services/refresh/requirements.txt +9 -0
- package/src/corpus/templates/semantic-search/services/refresh/src/app/__init__.py +0 -0
- package/src/corpus/templates/semantic-search/services/refresh/src/app/config.py +110 -0
- package/src/corpus/templates/semantic-search/services/refresh/src/app/db.py +89 -0
- package/src/corpus/templates/semantic-search/services/refresh/src/app/embeddings.py +44 -0
- package/src/corpus/templates/semantic-search/services/refresh/src/app/indexing.py +223 -0
- package/src/corpus/templates/semantic-search/services/refresh/src/app/source.py +259 -0
- package/src/corpus/templates/semantic-search/services/refresh/src/main.py +45 -0
- package/src/corpus/templates/semantic-search/services/web/index.html +12 -0
- package/src/corpus/templates/semantic-search/services/web/package-lock.json +1680 -0
- package/src/corpus/templates/semantic-search/services/web/package.json +19 -0
- package/src/corpus/templates/semantic-search/services/web/src/App.jsx +55 -0
- package/src/corpus/templates/semantic-search/services/web/src/api.js +125 -0
- package/src/corpus/templates/semantic-search/services/web/src/components/Dashboard.jsx +61 -0
- package/src/corpus/templates/semantic-search/services/web/src/components/ManagerLogin.jsx +39 -0
- package/src/corpus/templates/semantic-search/services/web/src/components/SearchView.jsx +166 -0
- package/src/corpus/templates/semantic-search/services/web/src/components/Settings.jsx +148 -0
- package/src/corpus/templates/semantic-search/services/web/src/main.jsx +10 -0
- package/src/corpus/templates/semantic-search/services/web/src/styles.css +78 -0
- package/src/corpus/templates/semantic-search/services/web/vite.config.js +10 -0
- package/src/corpus/workflows/semantic-search.md +142 -0
- package/src/tools.js +76 -14
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "semantic-search-web",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"react": "^18.3.1",
|
|
13
|
+
"react-dom": "^18.3.1"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@vitejs/plugin-react": "^4.3.1",
|
|
17
|
+
"vite": "^5.4.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import * as api from "./api.js";
|
|
3
|
+
import SearchView from "./components/SearchView.jsx";
|
|
4
|
+
import ManagerLogin from "./components/ManagerLogin.jsx";
|
|
5
|
+
import Settings from "./components/Settings.jsx";
|
|
6
|
+
import Dashboard from "./components/Dashboard.jsx";
|
|
7
|
+
|
|
8
|
+
export default function App() {
|
|
9
|
+
const [view, setView] = useState("search");
|
|
10
|
+
const [status, setStatus] = useState(null);
|
|
11
|
+
const [loggedIn, setLoggedIn] = useState(api.isLoggedIn());
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
api.getStatus().then(setStatus).catch(() => setStatus({ mongo: false }));
|
|
15
|
+
}, []);
|
|
16
|
+
|
|
17
|
+
function logout() {
|
|
18
|
+
api.setToken(null);
|
|
19
|
+
setLoggedIn(false);
|
|
20
|
+
setView("search");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="container">
|
|
25
|
+
<header className="site">
|
|
26
|
+
<h1>Document Search</h1>
|
|
27
|
+
<p>Search across your documents — by word, by meaning, and by date or collection</p>
|
|
28
|
+
<nav className="top">
|
|
29
|
+
<a onClick={() => setView("search")}>Search</a>
|
|
30
|
+
{loggedIn ? (
|
|
31
|
+
<>
|
|
32
|
+
<a onClick={() => setView("settings")}>Settings</a>
|
|
33
|
+
<a onClick={() => setView("dashboard")}>Dashboard</a>
|
|
34
|
+
<a onClick={logout}>Sign out</a>
|
|
35
|
+
</>
|
|
36
|
+
) : (
|
|
37
|
+
<a onClick={() => setView("login")}>Manager sign-in</a>
|
|
38
|
+
)}
|
|
39
|
+
</nav>
|
|
40
|
+
</header>
|
|
41
|
+
|
|
42
|
+
{view === "search" && <SearchView status={status} />}
|
|
43
|
+
{view === "login" && (
|
|
44
|
+
<ManagerLogin
|
|
45
|
+
onLoggedIn={() => {
|
|
46
|
+
setLoggedIn(true);
|
|
47
|
+
setView("settings");
|
|
48
|
+
}}
|
|
49
|
+
/>
|
|
50
|
+
)}
|
|
51
|
+
{view === "settings" && loggedIn && <Settings />}
|
|
52
|
+
{view === "dashboard" && loggedIn && <Dashboard />}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Backend base URL baked in at build time. The backend is mounted at /backend on
|
|
2
|
+
// the same entity, so same-origin "" works in production. Override with
|
|
3
|
+
// VITE_BACKEND_URL for split-origin local dev.
|
|
4
|
+
const BASE = import.meta.env.VITE_BACKEND_URL || "";
|
|
5
|
+
|
|
6
|
+
function url(path) {
|
|
7
|
+
return `${BASE}${path}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let token = localStorage.getItem("mgr_token") || null;
|
|
11
|
+
export function setToken(t) {
|
|
12
|
+
token = t;
|
|
13
|
+
if (t) localStorage.setItem("mgr_token", t);
|
|
14
|
+
else localStorage.removeItem("mgr_token");
|
|
15
|
+
}
|
|
16
|
+
export function isLoggedIn() {
|
|
17
|
+
return !!token;
|
|
18
|
+
}
|
|
19
|
+
function authHeaders() {
|
|
20
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function getStatus() {
|
|
24
|
+
const r = await fetch(url("/backend/status"));
|
|
25
|
+
return r.json();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function search(params) {
|
|
29
|
+
const qs = new URLSearchParams(
|
|
30
|
+
Object.entries(params).filter(([, v]) => v !== "" && v != null)
|
|
31
|
+
);
|
|
32
|
+
const r = await fetch(url(`/backend/search?${qs}`));
|
|
33
|
+
return r.json();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function count(params) {
|
|
37
|
+
const qs = new URLSearchParams(
|
|
38
|
+
Object.entries(params).filter(([, v]) => v !== "" && v != null)
|
|
39
|
+
);
|
|
40
|
+
const r = await fetch(url(`/backend/count?${qs}`));
|
|
41
|
+
return r.json();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function listCollections() {
|
|
45
|
+
const r = await fetch(url("/backend/collections"));
|
|
46
|
+
return r.json();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function answer(question) {
|
|
50
|
+
const r = await fetch(url("/backend/answer"), {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: { "Content-Type": "application/json" },
|
|
53
|
+
body: JSON.stringify({ question }),
|
|
54
|
+
});
|
|
55
|
+
return r.json();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function downloadUrl(documentId) {
|
|
59
|
+
return url(`/backend/documents/${documentId}/download`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── manager ──
|
|
63
|
+
export async function login(password) {
|
|
64
|
+
const r = await fetch(url("/backend/admin/login"), {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers: { "Content-Type": "application/json" },
|
|
67
|
+
body: JSON.stringify({ password }),
|
|
68
|
+
});
|
|
69
|
+
if (!r.ok) throw new Error((await r.json()).detail || "Login failed");
|
|
70
|
+
const data = await r.json();
|
|
71
|
+
setToken(data.token);
|
|
72
|
+
return data;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function getSettings() {
|
|
76
|
+
const r = await fetch(url("/backend/admin/settings"), { headers: authHeaders() });
|
|
77
|
+
if (!r.ok) throw new Error("Not authorized");
|
|
78
|
+
return r.json();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function updateSettings(body) {
|
|
82
|
+
const r = await fetch(url("/backend/admin/settings"), {
|
|
83
|
+
method: "PUT",
|
|
84
|
+
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
85
|
+
body: JSON.stringify(body),
|
|
86
|
+
});
|
|
87
|
+
return r.json();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function updateSynonyms(map) {
|
|
91
|
+
const r = await fetch(url("/backend/admin/synonyms"), {
|
|
92
|
+
method: "PUT",
|
|
93
|
+
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
94
|
+
body: JSON.stringify({ map }),
|
|
95
|
+
});
|
|
96
|
+
return r.json();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function changePassword(current, nw) {
|
|
100
|
+
const r = await fetch(url("/backend/admin/password"), {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
103
|
+
body: JSON.stringify({ current, new: nw }),
|
|
104
|
+
});
|
|
105
|
+
return r.json();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function indexReport() {
|
|
109
|
+
const r = await fetch(url("/backend/admin/index-report"), { headers: authHeaders() });
|
|
110
|
+
return r.json();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function refreshNow() {
|
|
114
|
+
const r = await fetch(url("/backend/admin/refresh"), {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: authHeaders(),
|
|
117
|
+
});
|
|
118
|
+
return r.json();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function usage() {
|
|
122
|
+
const r = await fetch(url("/backend/admin/usage"), { headers: authHeaders() });
|
|
123
|
+
if (!r.ok) throw new Error("Not authorized");
|
|
124
|
+
return r.json();
|
|
125
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import * as api from "../api.js";
|
|
3
|
+
|
|
4
|
+
export default function Dashboard() {
|
|
5
|
+
const [u, setU] = useState(null);
|
|
6
|
+
const [err, setErr] = useState(null);
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
api.usage().then(setU).catch((e) => setErr(e.message));
|
|
10
|
+
}, []);
|
|
11
|
+
|
|
12
|
+
if (err) return <div className="card note">{err}</div>;
|
|
13
|
+
if (!u) return <div className="card">Loading…</div>;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div>
|
|
17
|
+
<div className="card">
|
|
18
|
+
<h3>Usage overview</h3>
|
|
19
|
+
<div className="stat-grid">
|
|
20
|
+
<div className="stat"><div className="num">{u.total_searches}</div><div className="lbl">Searches</div></div>
|
|
21
|
+
<div className="stat"><div className="num">{u.unique_users}</div><div className="lbl">Unique users</div></div>
|
|
22
|
+
<div className="stat"><div className="num">{u.indexed_documents}</div><div className="lbl">Indexed documents</div></div>
|
|
23
|
+
</div>
|
|
24
|
+
<p className="note">Last refresh: {u.last_refresh || "never"}</p>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div className="card">
|
|
28
|
+
<h3>Searches by mode</h3>
|
|
29
|
+
<table className="usage">
|
|
30
|
+
<tbody>
|
|
31
|
+
{Object.entries(u.by_mode || {}).map(([mode, n]) => (
|
|
32
|
+
<tr key={mode}><td>{mode}</td><td>{n}</td></tr>
|
|
33
|
+
))}
|
|
34
|
+
</tbody>
|
|
35
|
+
</table>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div className="card">
|
|
39
|
+
<h3>Top queries</h3>
|
|
40
|
+
<table className="usage">
|
|
41
|
+
<tbody>
|
|
42
|
+
{(u.top_queries || []).map((q, i) => (
|
|
43
|
+
<tr key={i}><td>{q.query}</td><td>{q.count}</td></tr>
|
|
44
|
+
))}
|
|
45
|
+
</tbody>
|
|
46
|
+
</table>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div className="card">
|
|
50
|
+
<h3>Zero-result queries</h3>
|
|
51
|
+
<table className="usage">
|
|
52
|
+
<tbody>
|
|
53
|
+
{(u.zero_result_queries || []).map((q, i) => (
|
|
54
|
+
<tr key={i}><td>{q.query}</td><td>{q.count}</td></tr>
|
|
55
|
+
))}
|
|
56
|
+
</tbody>
|
|
57
|
+
</table>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import * as api from "../api.js";
|
|
3
|
+
|
|
4
|
+
export default function ManagerLogin({ onLoggedIn }) {
|
|
5
|
+
const [password, setPassword] = useState("");
|
|
6
|
+
const [err, setErr] = useState(null);
|
|
7
|
+
const [busy, setBusy] = useState(false);
|
|
8
|
+
|
|
9
|
+
async function submit(e) {
|
|
10
|
+
e.preventDefault();
|
|
11
|
+
setBusy(true);
|
|
12
|
+
setErr(null);
|
|
13
|
+
try {
|
|
14
|
+
await api.login(password);
|
|
15
|
+
onLoggedIn();
|
|
16
|
+
} catch (ex) {
|
|
17
|
+
setErr(ex.message);
|
|
18
|
+
} finally {
|
|
19
|
+
setBusy(false);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<form className="card" onSubmit={submit}>
|
|
25
|
+
<h3>Manager sign-in</h3>
|
|
26
|
+
<label className="field">Password</label>
|
|
27
|
+
<input
|
|
28
|
+
className="text-input"
|
|
29
|
+
type="password"
|
|
30
|
+
value={password}
|
|
31
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
32
|
+
/>
|
|
33
|
+
{err && <p className="note" style={{ color: "#b00" }}>{err}</p>}
|
|
34
|
+
<div style={{ marginTop: 12 }}>
|
|
35
|
+
<button disabled={busy}>{busy ? "Signing in…" : "Sign in"}</button>
|
|
36
|
+
</div>
|
|
37
|
+
</form>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import * as api from "../api.js";
|
|
3
|
+
|
|
4
|
+
const MODES = [
|
|
5
|
+
{ id: "hybrid", label: "Hybrid" },
|
|
6
|
+
{ id: "lexical", label: "Exact / keyword" },
|
|
7
|
+
{ id: "semantic", label: "Semantic" },
|
|
8
|
+
{ id: "metadata", label: "By date / collection" },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
// Render «...» highlight markers from the backend as <mark>.
|
|
12
|
+
function Passage({ text }) {
|
|
13
|
+
if (!text) return null;
|
|
14
|
+
const parts = text.split(/(«[^»]*»)/g);
|
|
15
|
+
return (
|
|
16
|
+
<div className="passage">
|
|
17
|
+
{parts.map((p, i) =>
|
|
18
|
+
p.startsWith("«") && p.endsWith("»") ? (
|
|
19
|
+
<mark key={i}>{p.slice(1, -1)}</mark>
|
|
20
|
+
) : (
|
|
21
|
+
<span key={i}>{p}</span>
|
|
22
|
+
)
|
|
23
|
+
)}
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default function SearchView({ status }) {
|
|
29
|
+
const [q, setQ] = useState("");
|
|
30
|
+
const [mode, setMode] = useState("hybrid");
|
|
31
|
+
const [collection, setCollection] = useState("");
|
|
32
|
+
const [dateFrom, setDateFrom] = useState("");
|
|
33
|
+
const [dateTo, setDateTo] = useState("");
|
|
34
|
+
const [collectionList, setCollectionList] = useState([]);
|
|
35
|
+
const [res, setRes] = useState(null);
|
|
36
|
+
const [loading, setLoading] = useState(false);
|
|
37
|
+
const [answerMode, setAnswerMode] = useState(false);
|
|
38
|
+
const [answerRes, setAnswerRes] = useState(null);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
api.listCollections().then((d) => setCollectionList(d.collections || [])).catch(() => {});
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
async function runSearch(e) {
|
|
45
|
+
e && e.preventDefault();
|
|
46
|
+
setLoading(true);
|
|
47
|
+
setAnswerRes(null);
|
|
48
|
+
try {
|
|
49
|
+
if (answerMode && q.trim()) {
|
|
50
|
+
setAnswerRes(await api.answer(q));
|
|
51
|
+
}
|
|
52
|
+
const params = { q, mode, collection, date_from: dateFrom, date_to: dateTo };
|
|
53
|
+
setRes(await api.search(params));
|
|
54
|
+
} finally {
|
|
55
|
+
setLoading(false);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const notIndexed = status && status.indexed_documents === 0;
|
|
60
|
+
const answerEnabled = status && status.answer_mode_enabled;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div>
|
|
64
|
+
{status && !status.mongo && (
|
|
65
|
+
<div className="banner">The service is not connected to the database right now.</div>
|
|
66
|
+
)}
|
|
67
|
+
{notIndexed && status.mongo && (
|
|
68
|
+
<div className="banner">
|
|
69
|
+
The catalog is not indexed yet
|
|
70
|
+
{!status.source_configured &&
|
|
71
|
+
` (source "${status.source_type}" is not configured)`}
|
|
72
|
+
{status.source_configured && !status.embeddings_configured &&
|
|
73
|
+
" — semantic search will be available once the embeddings key is set"}
|
|
74
|
+
.
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
77
|
+
|
|
78
|
+
<form className="card" onSubmit={runSearch}>
|
|
79
|
+
<div className="searchbar">
|
|
80
|
+
<input
|
|
81
|
+
type="text"
|
|
82
|
+
placeholder="Search a word, phrase, or idea…"
|
|
83
|
+
value={q}
|
|
84
|
+
onChange={(e) => setQ(e.target.value)}
|
|
85
|
+
/>
|
|
86
|
+
<button type="submit" disabled={loading}>
|
|
87
|
+
{loading ? "Searching…" : "Search"}
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div className="modes">
|
|
92
|
+
{MODES.map((m) => (
|
|
93
|
+
<span
|
|
94
|
+
key={m.id}
|
|
95
|
+
className={`chip ${mode === m.id ? "active" : ""}`}
|
|
96
|
+
onClick={() => setMode(m.id)}
|
|
97
|
+
>
|
|
98
|
+
{m.label}
|
|
99
|
+
</span>
|
|
100
|
+
))}
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div className="filters">
|
|
104
|
+
<select value={collection} onChange={(e) => setCollection(e.target.value)}>
|
|
105
|
+
<option value="">All collections</option>
|
|
106
|
+
{collectionList.map((c) => (
|
|
107
|
+
<option key={c} value={c}>{c}</option>
|
|
108
|
+
))}
|
|
109
|
+
</select>
|
|
110
|
+
<input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)} />
|
|
111
|
+
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)} />
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{answerEnabled && (
|
|
115
|
+
<div className="toggle" style={{ marginTop: 12 }}>
|
|
116
|
+
<input
|
|
117
|
+
id="answerMode"
|
|
118
|
+
type="checkbox"
|
|
119
|
+
checked={answerMode}
|
|
120
|
+
onChange={(e) => setAnswerMode(e.target.checked)}
|
|
121
|
+
/>
|
|
122
|
+
<label htmlFor="answerMode">Grounded answer mode</label>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
</form>
|
|
126
|
+
|
|
127
|
+
{answerRes && (
|
|
128
|
+
<div className="card">
|
|
129
|
+
<h3>Grounded answer</h3>
|
|
130
|
+
{answerRes.answer ? (
|
|
131
|
+
<>
|
|
132
|
+
<div className="answer-box">{answerRes.answer}</div>
|
|
133
|
+
<div className="citations">
|
|
134
|
+
Sources: {answerRes.citations.map((c) => c.title).join(" · ")}
|
|
135
|
+
</div>
|
|
136
|
+
</>
|
|
137
|
+
) : (
|
|
138
|
+
<p className="note">{answerRes.note}</p>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
{res && (
|
|
144
|
+
<div className="card">
|
|
145
|
+
<div className="note" style={{ marginBottom: 10 }}>
|
|
146
|
+
{res.total_matching_documents} matching documents
|
|
147
|
+
{res.note ? ` — ${res.note}` : ""}
|
|
148
|
+
</div>
|
|
149
|
+
{res.results.length === 0 && <p className="note">No results found.</p>}
|
|
150
|
+
{res.results.map((r) => (
|
|
151
|
+
<div className="result" key={r.document_id}>
|
|
152
|
+
<h3>{r.title}</h3>
|
|
153
|
+
<div className="meta">
|
|
154
|
+
{r.collection && <span>Collection: {r.collection} · </span>}
|
|
155
|
+
{r.date && <span>{r.date} · </span>}
|
|
156
|
+
{r.has_media && <span>🎧 media · </span>}
|
|
157
|
+
<a href={api.downloadUrl(r.document_id)}>Download text</a>
|
|
158
|
+
</div>
|
|
159
|
+
<Passage text={r.best_passage} />
|
|
160
|
+
</div>
|
|
161
|
+
))}
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import * as api from "../api.js";
|
|
3
|
+
|
|
4
|
+
export default function Settings() {
|
|
5
|
+
const [settings, setSettings] = useState(null);
|
|
6
|
+
const [synonymsText, setSynonymsText] = useState("");
|
|
7
|
+
const [report, setReport] = useState(null);
|
|
8
|
+
const [msg, setMsg] = useState(null);
|
|
9
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
10
|
+
const [pw, setPw] = useState({ current: "", nw: "" });
|
|
11
|
+
const [pwResult, setPwResult] = useState(null);
|
|
12
|
+
|
|
13
|
+
async function load() {
|
|
14
|
+
const s = await api.getSettings();
|
|
15
|
+
setSettings(s);
|
|
16
|
+
setSynonymsText(
|
|
17
|
+
Object.entries(s.synonyms || {})
|
|
18
|
+
.map(([k, v]) => `${k}: ${v.join(", ")}`)
|
|
19
|
+
.join("\n")
|
|
20
|
+
);
|
|
21
|
+
setReport(await api.indexReport());
|
|
22
|
+
}
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
load().catch((e) => setMsg(e.message));
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
async function saveFeatures() {
|
|
28
|
+
await api.updateSettings({ answer_mode_enabled: settings.answer_mode_enabled });
|
|
29
|
+
setMsg("Settings saved");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function saveSynonyms() {
|
|
33
|
+
const map = {};
|
|
34
|
+
synonymsText.split("\n").forEach((line) => {
|
|
35
|
+
const [k, rest] = line.split(":");
|
|
36
|
+
if (k && rest) map[k.trim()] = rest.split(",").map((x) => x.trim()).filter(Boolean);
|
|
37
|
+
});
|
|
38
|
+
await api.updateSynonyms(map);
|
|
39
|
+
setMsg("Synonyms saved");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function doRefresh() {
|
|
43
|
+
setRefreshing(true);
|
|
44
|
+
try {
|
|
45
|
+
await api.refreshNow();
|
|
46
|
+
setReport(await api.indexReport());
|
|
47
|
+
setMsg("Sync complete");
|
|
48
|
+
} finally {
|
|
49
|
+
setRefreshing(false);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function doChangePassword() {
|
|
54
|
+
const r = await api.changePassword(pw.current, pw.nw);
|
|
55
|
+
setPwResult(r);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!settings) return <div className="card">{msg || "Loading…"}</div>;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div>
|
|
62
|
+
{msg && <div className="banner">{msg}</div>}
|
|
63
|
+
|
|
64
|
+
<div className="card">
|
|
65
|
+
<h3>Index</h3>
|
|
66
|
+
<p className="note">
|
|
67
|
+
Re-index new and changed documents from the configured source. This is
|
|
68
|
+
the supported refresh path today. A scheduled (cron) refresh is a
|
|
69
|
+
follow-up, blocked on a platform issue — see AGENTS.md.
|
|
70
|
+
</p>
|
|
71
|
+
<button onClick={doRefresh} disabled={refreshing}>
|
|
72
|
+
{refreshing ? "Syncing…" : "Refresh now"}
|
|
73
|
+
</button>
|
|
74
|
+
<p className="note">Last refresh: {settings.last_refresh || "never"}</p>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div className="card">
|
|
78
|
+
<h3>Answer mode</h3>
|
|
79
|
+
<div className="toggle">
|
|
80
|
+
<input
|
|
81
|
+
id="am"
|
|
82
|
+
type="checkbox"
|
|
83
|
+
checked={settings.answer_mode_enabled}
|
|
84
|
+
onChange={(e) => setSettings({ ...settings, answer_mode_enabled: e.target.checked })}
|
|
85
|
+
/>
|
|
86
|
+
<label htmlFor="am">Enable grounded answer mode</label>
|
|
87
|
+
</div>
|
|
88
|
+
<button className="ghost" onClick={saveFeatures} style={{ marginTop: 8 }}>Save</button>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div className="card">
|
|
92
|
+
<h3>Synonyms</h3>
|
|
93
|
+
<p className="note">One line per term, format: <code>term: synonym1, synonym2</code></p>
|
|
94
|
+
<textarea
|
|
95
|
+
className="text-input"
|
|
96
|
+
rows={5}
|
|
97
|
+
value={synonymsText}
|
|
98
|
+
onChange={(e) => setSynonymsText(e.target.value)}
|
|
99
|
+
/>
|
|
100
|
+
<button className="ghost" onClick={saveSynonyms} style={{ marginTop: 8 }}>Save synonyms</button>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div className="card">
|
|
104
|
+
<h3>Index report</h3>
|
|
105
|
+
{report && report.reports && report.reports.length > 0 ? (
|
|
106
|
+
report.reports.slice(0, 1).map((rep, i) => (
|
|
107
|
+
<div key={i}>
|
|
108
|
+
<p className="note">
|
|
109
|
+
{rep.status} — {rep.started_at}
|
|
110
|
+
{rep.note ? ` — ${rep.note}` : ""}
|
|
111
|
+
</p>
|
|
112
|
+
<table className="usage">
|
|
113
|
+
<tbody>
|
|
114
|
+
{(rep.files || []).slice(0, 50).map((f, j) => (
|
|
115
|
+
<tr key={j}>
|
|
116
|
+
<td>{f.file}</td>
|
|
117
|
+
<td>{f.result}</td>
|
|
118
|
+
<td>{f.chunks ?? ""}</td>
|
|
119
|
+
</tr>
|
|
120
|
+
))}
|
|
121
|
+
</tbody>
|
|
122
|
+
</table>
|
|
123
|
+
</div>
|
|
124
|
+
))
|
|
125
|
+
) : (
|
|
126
|
+
<p className="note">No reports yet.</p>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div className="card">
|
|
131
|
+
<h3>Change password</h3>
|
|
132
|
+
<label className="field">Current password</label>
|
|
133
|
+
<input className="text-input" type="password"
|
|
134
|
+
value={pw.current} onChange={(e) => setPw({ ...pw, current: e.target.value })} />
|
|
135
|
+
<label className="field">New password</label>
|
|
136
|
+
<input className="text-input" type="password"
|
|
137
|
+
value={pw.nw} onChange={(e) => setPw({ ...pw, nw: e.target.value })} />
|
|
138
|
+
<button className="ghost" onClick={doChangePassword} style={{ marginTop: 8 }}>Change password</button>
|
|
139
|
+
{pwResult && (
|
|
140
|
+
<div className="banner" style={{ marginTop: 10 }}>
|
|
141
|
+
<div>{pwResult.note}</div>
|
|
142
|
+
<code style={{ wordBreak: "break-all" }}>MANAGER_PASSWORD_HASH={pwResult.new_hash}</code>
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|