@modusensus/dsh-mneme 0.7.11 → 0.7.13
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/README.en.md +69 -0
- package/README.md +227 -21
- package/bin/cli.mjs +603 -0
- package/lib/api-standalone.js +264 -0
- package/lib/api.js +91 -2
- package/lib/client.js +243 -1
- package/lib/config.js +80 -0
- package/lib/index.js +43 -12
- package/lib/quality-filter.js +4 -1
- package/lib/service.js +38 -5
- package/lib/settings.js +40 -0
- package/lib/store.js +4 -1
- package/lib/summarize.js +197 -59
- package/lib/tools.js +3 -3
- package/package.json +7 -1
- package/src/api-standalone.js +264 -0
- package/src/api.js +91 -2
- package/src/config.js +80 -0
- package/src/index.js +43 -12
- package/src/quality-filter.js +4 -1
- package/src/service.js +38 -5
- package/src/settings.js +40 -0
- package/src/store.js +4 -1
- package/src/summarize.js +197 -59
- package/src/tools.js +3 -3
- package/test/api.test.js +44 -0
- package/test/helpers/peer-worker.mjs +10 -0
- package/test/peer-blockers.test.js +4 -9
- package/test/settings.test.js +24 -0
- package/test/standalone-api.test.js +326 -0
- package/test/summarize.test.js +116 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// Standalone HTTP API (v0.7.12): a plain node:http server for ecosystem
|
|
2
|
+
// integrations that live outside the DSH host and cannot reach the plugin's
|
|
3
|
+
// internal webServer routes (/api/dsh-mneme/*). Mirrors the JSON semantics of
|
|
4
|
+
// those routes but with mandatory Bearer-token auth on everything except
|
|
5
|
+
// GET /health, so the store can be exposed safely on loopback.
|
|
6
|
+
//
|
|
7
|
+
// Security: the default bind host is 127.0.0.1. Pointing externalApiHost at a
|
|
8
|
+
// non-loopback address exposes the whole memory store to the network — that is
|
|
9
|
+
// the operator's explicit responsibility (documented in README).
|
|
10
|
+
import { createServer } from "node:http";
|
|
11
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
12
|
+
import { TYPES } from "./store.js";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_PORT = 8790;
|
|
15
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
16
|
+
// Hardcoded release version (package.json is bumped at publish time and may
|
|
17
|
+
// lag the code that ships in between).
|
|
18
|
+
const VERSION = "0.7.12";
|
|
19
|
+
|
|
20
|
+
function sendJson(res, status, payload) {
|
|
21
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
22
|
+
res.end(JSON.stringify(payload));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** True when the request carries the configured token. */
|
|
26
|
+
function isAuthorized(req, apiToken) {
|
|
27
|
+
const raw = req.headers?.authorization ?? req.headers?.["x-dsh-mneme-token"] ?? "";
|
|
28
|
+
const token = raw.startsWith("Bearer ") ? raw.slice(7).trim() : raw.trim();
|
|
29
|
+
if (token === "" || token.length !== apiToken.length) return false;
|
|
30
|
+
// Constant-time comparison: no timing oracle on the token.
|
|
31
|
+
return timingSafeEqual(Buffer.from(token), Buffer.from(apiToken));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Collect the request body as text (tolerant of transport errors). */
|
|
35
|
+
function readBody(req) {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
let body = "";
|
|
38
|
+
req.on("data", (chunk) => { body += chunk; });
|
|
39
|
+
req.on("end", () => resolve(body));
|
|
40
|
+
req.on("error", () => resolve(""));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create (and start) the standalone API server.
|
|
46
|
+
* Accepts { service, store, config, logger, settings, port, host }:
|
|
47
|
+
* - token: persisted settings kv "external_api" wins; auto-generated
|
|
48
|
+
* (crypto.randomBytes(24).toString("base64url")) and persisted when empty.
|
|
49
|
+
* - port: explicit arg > persisted settings > config.externalApiPort > 8790.
|
|
50
|
+
* - host: explicit arg > config.externalApiHost > "127.0.0.1".
|
|
51
|
+
* Returns { server, port, host, token, ready }: `port` is the effective bound
|
|
52
|
+
* port (updated to the OS-assigned one after `ready` resolves when asked to
|
|
53
|
+
* bind port 0), `ready` resolves once listening and rejects if the bind fails.
|
|
54
|
+
*/
|
|
55
|
+
export function createStandaloneApi({ service, store, config = {}, logger, settings, port, host }) {
|
|
56
|
+
const persisted = settings?.getExternalApi?.() ?? {};
|
|
57
|
+
|
|
58
|
+
let token = typeof persisted.token === "string" ? persisted.token : "";
|
|
59
|
+
if (!token) {
|
|
60
|
+
token = randomBytes(24).toString("base64url");
|
|
61
|
+
// Persist only the token; enabled/port keys stay as they are (merge).
|
|
62
|
+
try {
|
|
63
|
+
settings?.setExternalApi?.({ token });
|
|
64
|
+
} catch (error) {
|
|
65
|
+
logger?.warn?.(`[dsh-mneme] standalone API token persistence failed: ${String(error)}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Persisted host (set from the panel) wins over the bundle config, same
|
|
70
|
+
// precedence as port; the explicit argument still wins over both.
|
|
71
|
+
const boundHost = host
|
|
72
|
+
?? (typeof persisted.host === "string" && persisted.host ? persisted.host : undefined)
|
|
73
|
+
?? config.externalApiHost
|
|
74
|
+
?? DEFAULT_HOST;
|
|
75
|
+
const persistedPort = Number(persisted.port);
|
|
76
|
+
const boundPort = port
|
|
77
|
+
?? (Number.isInteger(persistedPort) && persistedPort > 0 ? persistedPort : undefined)
|
|
78
|
+
?? config.externalApiPort
|
|
79
|
+
?? DEFAULT_PORT;
|
|
80
|
+
|
|
81
|
+
const server = createServer((req, res) => {
|
|
82
|
+
try {
|
|
83
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
84
|
+
const pathname = url.pathname;
|
|
85
|
+
|
|
86
|
+
// Health is the single unauthenticated probe (monitor checks).
|
|
87
|
+
if (req.method === "GET" && pathname === "/health") {
|
|
88
|
+
sendJson(res, 200, { ok: true });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Everything else requires the Bearer token.
|
|
93
|
+
if (!isAuthorized(req, token)) {
|
|
94
|
+
sendJson(res, 401, { error: "unauthorized" });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- GET /status: version + store shape + uptime -----------------------
|
|
99
|
+
if (req.method === "GET" && pathname === "/status") {
|
|
100
|
+
const byType = {};
|
|
101
|
+
for (const type of TYPES) byType[type] = service.count(type);
|
|
102
|
+
let entities = 0;
|
|
103
|
+
try {
|
|
104
|
+
entities = store.db.prepare("SELECT count(*) AS c FROM entities").get().c;
|
|
105
|
+
} catch { /* entities storage unavailable → 0 */ }
|
|
106
|
+
sendJson(res, 200, {
|
|
107
|
+
version: VERSION,
|
|
108
|
+
memories: { total: service.count(), byType },
|
|
109
|
+
entities,
|
|
110
|
+
uptime_s: Math.floor(process.uptime())
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --- GET /memories: paged + filtered list (same semantics as the
|
|
116
|
+
// internal /api/dsh-mneme/list) ------------------------------------
|
|
117
|
+
if (req.method === "GET" && pathname === "/memories") {
|
|
118
|
+
const type = url.searchParams.get("type") ?? undefined;
|
|
119
|
+
const limit = Number(url.searchParams.get("limit") ?? 50);
|
|
120
|
+
const offset = Number(url.searchParams.get("offset") ?? 0);
|
|
121
|
+
const order = url.searchParams.get("order") ?? undefined;
|
|
122
|
+
const minRaw = url.searchParams.get("minImportance");
|
|
123
|
+
const minImportance = minRaw !== null && minRaw !== "" && !Number.isNaN(Number(minRaw))
|
|
124
|
+
? Number(minRaw)
|
|
125
|
+
: undefined;
|
|
126
|
+
const source = url.searchParams.get("source") || undefined;
|
|
127
|
+
const items = service.toApiList(service.list({ type, limit, offset, order, minImportance, source }));
|
|
128
|
+
// Total honors the same filters so pager math stays correct.
|
|
129
|
+
sendJson(res, 200, { items, total: service.count(type, { minImportance, source }) });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- GET/DELETE /memories/:id ------------------------------------------
|
|
134
|
+
const idMatch = pathname.match(/^\/memories\/([^/]+)$/);
|
|
135
|
+
if (idMatch) {
|
|
136
|
+
let id = idMatch[1];
|
|
137
|
+
try { id = decodeURIComponent(id); } catch { /* keep raw */ }
|
|
138
|
+
if (req.method === "GET") {
|
|
139
|
+
const row = service.getById(id);
|
|
140
|
+
if (!row) {
|
|
141
|
+
sendJson(res, 404, { error: "not-found" });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
sendJson(res, 200, service.toApiList([row])[0]);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (req.method === "DELETE") {
|
|
148
|
+
// store.remove deletes silently — precheck for a distinguishable 404.
|
|
149
|
+
if (!service.getById(id)) {
|
|
150
|
+
sendJson(res, 404, { error: "not-found" });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
service.remove(id);
|
|
154
|
+
sendJson(res, 200, { ok: true });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
sendJson(res, 404, { error: "not-found" });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- POST /memories: save with title-dedupe (safer than raw save) ------
|
|
162
|
+
if (req.method === "POST" && pathname === "/memories") {
|
|
163
|
+
void readBody(req).then((text) => {
|
|
164
|
+
let body;
|
|
165
|
+
try {
|
|
166
|
+
body = JSON.parse(text || "{}");
|
|
167
|
+
} catch {
|
|
168
|
+
sendJson(res, 400, { error: "invalid-json" });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
172
|
+
sendJson(res, 400, { error: "invalid-body" });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// Pre-validate what store.save would throw on, so clients get a
|
|
176
|
+
// clean 400 instead of a leaked SQLite error.
|
|
177
|
+
if (!TYPES.has(body.type)) {
|
|
178
|
+
sendJson(res, 400, { error: "invalid-type" });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (typeof body.title !== "string" || !body.title.trim()) {
|
|
182
|
+
sendJson(res, 400, { error: "missing-title" });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (typeof body.content !== "string") {
|
|
186
|
+
sendJson(res, 400, { error: "missing-content" });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (body.tags !== undefined && !Array.isArray(body.tags)) {
|
|
190
|
+
sendJson(res, 400, { error: "tags-must-be-an-array" });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
const { action, memory } = service.saveWithDedupe({
|
|
195
|
+
type: body.type,
|
|
196
|
+
title: body.title,
|
|
197
|
+
content: body.content,
|
|
198
|
+
importance: body.importance,
|
|
199
|
+
tags: body.tags,
|
|
200
|
+
source: body.source
|
|
201
|
+
});
|
|
202
|
+
sendJson(res, action === "created" ? 201 : 200, service.toApiList([memory])[0]);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
logger?.warn?.(`[dsh-mneme] standalone API save failed: ${String(error)}`);
|
|
205
|
+
sendJson(res, 500, { error: "internal" });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- GET /search: unified recall pipeline (keyword + vector + BM25) ----
|
|
212
|
+
if (req.method === "GET" && pathname === "/search") {
|
|
213
|
+
const q = url.searchParams.get("q") ?? "";
|
|
214
|
+
const limit = Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 20);
|
|
215
|
+
const mode = url.searchParams.get("mode") ?? "auto";
|
|
216
|
+
const rerank = url.searchParams.get("rerank") !== "false";
|
|
217
|
+
const query = q.trim();
|
|
218
|
+
if (!query) {
|
|
219
|
+
sendJson(res, 200, { items: [], mode: "keyword" });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
// Any vector/rerank failure degrades to keyword inside searchMemories.
|
|
223
|
+
void Promise.resolve(
|
|
224
|
+
service.searchMemories(query, { mode, topK: limit, useRerank: rerank })
|
|
225
|
+
).then((rows) => {
|
|
226
|
+
const used = rows.some((m) => m.vector === true) ? "vector" : "keyword";
|
|
227
|
+
sendJson(res, 200, { items: service.toApiList(rows), mode: used });
|
|
228
|
+
}).catch(() => {
|
|
229
|
+
sendJson(res, 200, { items: service.toApiList(service.search(query, { limit })), mode: "keyword" });
|
|
230
|
+
});
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
sendJson(res, 404, { error: "not-found" });
|
|
235
|
+
} catch {
|
|
236
|
+
sendJson(res, 500, { error: "internal" });
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// A permanent error listener keeps an EADDRINUSE / runtime socket error from
|
|
241
|
+
// crashing the host process; `ready` still surfaces the first bind failure.
|
|
242
|
+
server.on("error", (error) => {
|
|
243
|
+
logger?.warn?.(`[dsh-mneme] standalone API error: ${String(error)}`);
|
|
244
|
+
});
|
|
245
|
+
const ready = new Promise((resolve, reject) => {
|
|
246
|
+
server.once("listening", resolve);
|
|
247
|
+
server.once("error", reject);
|
|
248
|
+
});
|
|
249
|
+
server.listen(boundPort, boundHost);
|
|
250
|
+
ready.then(() => {
|
|
251
|
+
const address = server.address();
|
|
252
|
+
if (address && typeof address === "object") {
|
|
253
|
+
logger?.info?.(`[dsh-mneme] standalone API listening on http://${address.address}:${address.port}`);
|
|
254
|
+
}
|
|
255
|
+
}).catch(() => { /* already logged by the error handler above */ });
|
|
256
|
+
|
|
257
|
+
const api = { server, port: boundPort, host: boundHost, token, ready };
|
|
258
|
+
// After the OS assigns the real port (bind port 0), reflect it for callers.
|
|
259
|
+
ready.then(() => {
|
|
260
|
+
const address = server.address();
|
|
261
|
+
if (address && typeof address === "object") api.port = address.port;
|
|
262
|
+
}).catch(() => { /* bind failed: port stays as configured */ });
|
|
263
|
+
return api;
|
|
264
|
+
}
|
package/lib/api.js
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import { URL } from "node:url";
|
|
2
|
-
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
3
3
|
|
|
4
4
|
function sendJson(res, status, payload) {
|
|
5
5
|
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
6
6
|
res.end(JSON.stringify(payload));
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
// Defaults for the standalone external API — keep in step with the schema
|
|
10
|
+
// defaults in config.js (externalApiPort / externalApiHost).
|
|
11
|
+
const EXTERNAL_API_DEFAULTS = { enabled: false, port: 8790, host: "127.0.0.1" };
|
|
12
|
+
|
|
13
|
+
/** Merge persisted external-api kv over the defaults for client display. */
|
|
14
|
+
function fullExternalConfig(kv = {}) {
|
|
15
|
+
return {
|
|
16
|
+
enabled: kv.enabled === true,
|
|
17
|
+
port: Number.isInteger(kv.port) && kv.port > 0 ? kv.port : EXTERNAL_API_DEFAULTS.port,
|
|
18
|
+
host: kv.host || EXTERNAL_API_DEFAULTS.host,
|
|
19
|
+
token: kv.token || ""
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
9
23
|
/**
|
|
10
24
|
* Mask an API key for client display: keep a recognizable prefix and suffix,
|
|
11
25
|
* hide the middle. Empty keys stay empty; short keys are fully hidden.
|
|
@@ -535,6 +549,81 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
535
549
|
}
|
|
536
550
|
});
|
|
537
551
|
|
|
552
|
+
// --- panel mode (v0.7.12): light / standard -------------------------------
|
|
553
|
+
// Persists the Web panel's feature preset into the settings kv
|
|
554
|
+
// ("panel_mode"). PUT requires auth like every other settings write; the
|
|
555
|
+
// value is validated against the enum. index.js applies the light preset on
|
|
556
|
+
// the next boot (persisted mode wins over the bundle config).
|
|
557
|
+
register({
|
|
558
|
+
kind: "exact",
|
|
559
|
+
path: "/api/dsh-mneme/mode",
|
|
560
|
+
handler(req, res) {
|
|
561
|
+
try {
|
|
562
|
+
if (req.method === "PUT" || req.method === "POST") {
|
|
563
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
564
|
+
return readBody(req).then((text) => {
|
|
565
|
+
const body = parseBody(text);
|
|
566
|
+
if (body.mode !== "light" && body.mode !== "standard") {
|
|
567
|
+
sendJson(res, 400, { error: "invalid-mode" });
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
settings.setPanelMode(body.mode);
|
|
571
|
+
sendJson(res, 200, { mode: settings.getPanelMode() });
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
sendJson(res, 200, { mode: settings.getPanelMode() });
|
|
575
|
+
} catch {
|
|
576
|
+
sendJson(res, 500, { error: "internal" });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// --- external API settings (the standalone server's panel-facing config) ---
|
|
582
|
+
register({
|
|
583
|
+
kind: "exact",
|
|
584
|
+
path: "/api/dsh-mneme/external-api",
|
|
585
|
+
handler(req, res) {
|
|
586
|
+
try {
|
|
587
|
+
if (req.method === "PUT" || req.method === "POST") {
|
|
588
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
589
|
+
return readBody(req).then((text) => {
|
|
590
|
+
const body = parseBody(text);
|
|
591
|
+
const patch = {};
|
|
592
|
+
if (body.enabled !== undefined) patch.enabled = body.enabled === true;
|
|
593
|
+
if (body.port !== undefined) {
|
|
594
|
+
const port = Number(body.port);
|
|
595
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
596
|
+
sendJson(res, 400, { error: "invalid-port" });
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
patch.port = port;
|
|
600
|
+
}
|
|
601
|
+
if (body.host !== undefined) {
|
|
602
|
+
const host = String(body.host).trim();
|
|
603
|
+
if (!host || host.includes("://")) {
|
|
604
|
+
sendJson(res, 400, { error: "invalid-host" });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
patch.host = host;
|
|
608
|
+
}
|
|
609
|
+
sendJson(res, 200, { config: fullExternalConfig(settings.setExternalApi(patch)) });
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
// GET: always hand back a token — the standalone server generates its
|
|
613
|
+
// own on first enabled boot, but the panel displays it before that,
|
|
614
|
+
// so materialize and persist one here.
|
|
615
|
+
const kv = settings.getExternalApi() ?? {};
|
|
616
|
+
if (!kv.token) {
|
|
617
|
+
kv.token = randomBytes(24).toString("base64url");
|
|
618
|
+
settings.setExternalApi({ token: kv.token });
|
|
619
|
+
}
|
|
620
|
+
sendJson(res, 200, { config: fullExternalConfig(kv) });
|
|
621
|
+
} catch {
|
|
622
|
+
sendJson(res, 500, { error: "internal" });
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
538
627
|
// --- custom commands ---
|
|
539
628
|
register({
|
|
540
629
|
kind: "exact",
|
|
@@ -573,7 +662,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
573
662
|
});
|
|
574
663
|
|
|
575
664
|
return {
|
|
576
|
-
routes:
|
|
665
|
+
routes: 17,
|
|
577
666
|
dispose: () => {
|
|
578
667
|
for (const dispose of disposers) dispose();
|
|
579
668
|
}
|
package/lib/client.js
CHANGED
|
@@ -195,6 +195,23 @@ window.__ModuleLoader__.load({
|
|
|
195
195
|
"memory.settings.apiTokenPlaceholder": "留空 = 不鉴权(默认)",
|
|
196
196
|
"memory.settings.apiTokenSave": "保存 Token",
|
|
197
197
|
"memory.settings.apiTokenSaved": "Token 已保存",
|
|
198
|
+
"memory.settings.mode.title": "运行模式",
|
|
199
|
+
"memory.settings.mode.desc": "轻量模式只保留核心的记忆读写与自动注入(关闭 autoDream 巩固、实体抽取、语义搜索等高级功能),适合只想「记住偏好」的轻量使用;标准模式开启全部功能。",
|
|
200
|
+
"memory.settings.mode.light": "轻量",
|
|
201
|
+
"memory.settings.mode.standard": "标准",
|
|
202
|
+
"memory.settings.mode.savedHint": "已保存,重启 DSH 后生效",
|
|
203
|
+
"memory.settings.mode.offList": "已关闭:巩固(autoDream)· 实体抽取 · 语义搜索",
|
|
204
|
+
"memory.settings.extapi.title": "外部访问 API",
|
|
205
|
+
"memory.settings.extapi.desc": "独立 HTTP 服务,供其他插件 / CLI / 桌面工具读写记忆,默认绑定 127.0.0.1。重启 DSH 后生效。",
|
|
206
|
+
"memory.settings.extapi.enabled": "启用",
|
|
207
|
+
"memory.settings.extapi.disabled": "停用",
|
|
208
|
+
"memory.settings.extapi.address": "地址",
|
|
209
|
+
"memory.settings.extapi.port": "端口",
|
|
210
|
+
"memory.settings.extapi.token": "Token",
|
|
211
|
+
"memory.settings.extapi.copy": "复制",
|
|
212
|
+
"memory.settings.extapi.copied": "已复制",
|
|
213
|
+
"memory.settings.extapi.savedHint": "已保存,重启 DSH 后生效",
|
|
214
|
+
"memory.settings.extapi.invalidPort": "端口需为 1-65535 的数字",
|
|
198
215
|
"memory.explorer.delete": "删除",
|
|
199
216
|
"memory.explorer.confirmDelete": "确认删除?",
|
|
200
217
|
"memory.explorer.cancel": "取消",
|
|
@@ -312,6 +329,23 @@ window.__ModuleLoader__.load({
|
|
|
312
329
|
"memory.settings.apiTokenPlaceholder": "Empty = no auth (default)",
|
|
313
330
|
"memory.settings.apiTokenSave": "Save Token",
|
|
314
331
|
"memory.settings.apiTokenSaved": "Token saved",
|
|
332
|
+
"memory.settings.mode.title": "Runtime mode",
|
|
333
|
+
"memory.settings.mode.desc": "Light mode keeps only the core memory read/write and auto-injection (autoDream consolidation, entity extraction and semantic search are off) — for light use where you just want preferences remembered. Standard mode enables everything.",
|
|
334
|
+
"memory.settings.mode.light": "Light",
|
|
335
|
+
"memory.settings.mode.standard": "Standard",
|
|
336
|
+
"memory.settings.mode.savedHint": "Saved. Takes effect after restarting DSH",
|
|
337
|
+
"memory.settings.mode.offList": "Off: consolidation (autoDream) · entity extraction · semantic search",
|
|
338
|
+
"memory.settings.extapi.title": "External API",
|
|
339
|
+
"memory.settings.extapi.desc": "A standalone HTTP service for other plugins / CLIs / desktop tools to read and write memories, bound to 127.0.0.1 by default. Takes effect after restarting DSH.",
|
|
340
|
+
"memory.settings.extapi.enabled": "Enable",
|
|
341
|
+
"memory.settings.extapi.disabled": "Disable",
|
|
342
|
+
"memory.settings.extapi.address": "Address",
|
|
343
|
+
"memory.settings.extapi.port": "Port",
|
|
344
|
+
"memory.settings.extapi.token": "Token",
|
|
345
|
+
"memory.settings.extapi.copy": "Copy",
|
|
346
|
+
"memory.settings.extapi.copied": "Copied",
|
|
347
|
+
"memory.settings.extapi.savedHint": "Saved. Takes effect after restarting DSH",
|
|
348
|
+
"memory.settings.extapi.invalidPort": "Port must be a number between 1 and 65535",
|
|
315
349
|
"memory.explorer.delete": "Delete",
|
|
316
350
|
"memory.explorer.confirmDelete": "Confirm delete?",
|
|
317
351
|
"memory.explorer.cancel": "Cancel",
|
|
@@ -547,7 +581,13 @@ window.__ModuleLoader__.load({
|
|
|
547
581
|
".mneme-btndanger{color:var(--dsw-alias-state-error,#c33);border-color:var(--dsw-alias-state-error,#c33)}",
|
|
548
582
|
".mneme-btndanger:hover{background:color-mix(in srgb,var(--dsw-alias-state-error,#c33) 12%,transparent)}",
|
|
549
583
|
".mneme-btndangerconfirm{background:var(--dsw-alias-state-error,#c33);border-color:var(--dsw-alias-state-error,#c33);color:#fff}",
|
|
550
|
-
".mneme-btndangerconfirm:hover{filter:brightness(.9)}"
|
|
584
|
+
".mneme-btndangerconfirm:hover{filter:brightness(.9)}",
|
|
585
|
+
// --- settings cards: boxed cards for the runtime-mode and external-API
|
|
586
|
+
// sections — each card owns its fetch/PUT state, so it renders as a
|
|
587
|
+
// self-contained unit inside the stacked settings view ---
|
|
588
|
+
".mneme-set-card{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:14px;margin-bottom:12px}",
|
|
589
|
+
".mneme-set-token{font-family:monospace;font-size:12px;padding:7px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-base,transparent);color:var(--dsw-alias-label-primary);word-break:break-all;user-select:all}",
|
|
590
|
+
".mneme-set-hint{font-size:12px;color:var(--dsw-alias-label-tertiary)}"
|
|
551
591
|
].join("\n");
|
|
552
592
|
if (typeof document !== "undefined" && document.querySelector(`style[data-plugin-css="${CSS_TAG}"]`) === null) {
|
|
553
593
|
const tag = document.createElement("style");
|
|
@@ -1086,6 +1126,21 @@ window.__ModuleLoader__.load({
|
|
|
1086
1126
|
(typeof window !== "undefined" && window.localStorage) ? window.localStorage.getItem("dsh-mneme-api-token") || "" : ""
|
|
1087
1127
|
);
|
|
1088
1128
|
const [apiTokenSaved, setApiTokenSaved] = react.useState(false);
|
|
1129
|
+
// 运行模式 — light vs standard; null = still loading. The card keeps
|
|
1130
|
+
// its own busy/saved/error state so it never blocks the others.
|
|
1131
|
+
const [mode, setMode] = react.useState(null);
|
|
1132
|
+
const [modeBusy, setModeBusy] = react.useState(false);
|
|
1133
|
+
const [modeSaved, setModeSaved] = react.useState(false);
|
|
1134
|
+
const [modeError, setModeError] = react.useState("");
|
|
1135
|
+
// 外部访问 API — config fetched from the backend (the token is generated
|
|
1136
|
+
// and kept server-side); host/port inputs are drafts, PUT only on save.
|
|
1137
|
+
const [extapi, setExtapi] = react.useState(null);
|
|
1138
|
+
const [extapiHost, setExtapiHost] = react.useState("127.0.0.1");
|
|
1139
|
+
const [extapiPort, setExtapiPort] = react.useState("");
|
|
1140
|
+
const [extapiBusy, setExtapiBusy] = react.useState(false);
|
|
1141
|
+
const [extapiSaved, setExtapiSaved] = react.useState(false);
|
|
1142
|
+
const [extapiCopied, setExtapiCopied] = react.useState(false);
|
|
1143
|
+
const [extapiError, setExtapiError] = react.useState("");
|
|
1089
1144
|
|
|
1090
1145
|
const load = react.useCallback(async () => {
|
|
1091
1146
|
try {
|
|
@@ -1104,6 +1159,34 @@ window.__ModuleLoader__.load({
|
|
|
1104
1159
|
|
|
1105
1160
|
react.useEffect(() => { load(); }, [load]);
|
|
1106
1161
|
|
|
1162
|
+
// Runtime mode + external API — two independent fetches: one failing
|
|
1163
|
+
// endpoint only errors its own card, never the other one.
|
|
1164
|
+
react.useEffect(() => {
|
|
1165
|
+
let cancelled = false;
|
|
1166
|
+
const toErr = (err) => (err && err.message) || "failed";
|
|
1167
|
+
apiFetch("/api/dsh-mneme/mode")
|
|
1168
|
+
.then((res) => { if (!res.ok) throw new Error("HTTP " + res.status); return res.json(); })
|
|
1169
|
+
.then((j) => { if (!cancelled) setMode(j.mode === "light" ? "light" : "standard"); })
|
|
1170
|
+
.catch((err) => { if (!cancelled) setModeError(toErr(err)); });
|
|
1171
|
+
apiFetch("/api/dsh-mneme/external-api")
|
|
1172
|
+
.then((res) => { if (!res.ok) throw new Error("HTTP " + res.status); return res.json(); })
|
|
1173
|
+
.then((j) => {
|
|
1174
|
+
if (cancelled) return;
|
|
1175
|
+
const cfg = (j && j.config) || {};
|
|
1176
|
+
const next = {
|
|
1177
|
+
enabled: !!cfg.enabled,
|
|
1178
|
+
host: cfg.host || "127.0.0.1",
|
|
1179
|
+
port: Number(cfg.port) || 0,
|
|
1180
|
+
token: cfg.token || ""
|
|
1181
|
+
};
|
|
1182
|
+
setExtapi(next);
|
|
1183
|
+
setExtapiHost(next.host);
|
|
1184
|
+
setExtapiPort(next.port ? String(next.port) : "");
|
|
1185
|
+
})
|
|
1186
|
+
.catch((err) => { if (!cancelled) setExtapiError(toErr(err)); });
|
|
1187
|
+
return () => { cancelled = true; };
|
|
1188
|
+
}, []);
|
|
1189
|
+
|
|
1107
1190
|
async function saveProfile() {
|
|
1108
1191
|
try {
|
|
1109
1192
|
await apiFetch("/api/dsh-mneme/profile", {
|
|
@@ -1195,6 +1278,85 @@ window.__ModuleLoader__.load({
|
|
|
1195
1278
|
} catch { /* ignore */ }
|
|
1196
1279
|
}
|
|
1197
1280
|
|
|
1281
|
+
// Runtime mode: optimistic chip flip, rolled back on failure. Both
|
|
1282
|
+
// changes only take effect after a DSH restart — the saved hint says so.
|
|
1283
|
+
async function saveMode(next) {
|
|
1284
|
+
if (modeBusy) return;
|
|
1285
|
+
const prev = mode;
|
|
1286
|
+
setModeBusy(true);
|
|
1287
|
+
setModeError("");
|
|
1288
|
+
setMode(next);
|
|
1289
|
+
try {
|
|
1290
|
+
const res = await apiFetch("/api/dsh-mneme/mode", {
|
|
1291
|
+
method: "PUT",
|
|
1292
|
+
headers: { "Content-Type": "application/json" },
|
|
1293
|
+
body: JSON.stringify({ mode: next })
|
|
1294
|
+
});
|
|
1295
|
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
1296
|
+
setModeSaved(true);
|
|
1297
|
+
setTimeout(() => setModeSaved(false), 2500);
|
|
1298
|
+
} catch (err) {
|
|
1299
|
+
setMode(prev);
|
|
1300
|
+
setModeError((err && err.message) || "failed");
|
|
1301
|
+
}
|
|
1302
|
+
setModeBusy(false);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// External API: the backend owns the token and returns the full config,
|
|
1306
|
+
// so every PUT response refreshes host/port/token from the server.
|
|
1307
|
+
async function putExtapi(body) {
|
|
1308
|
+
setExtapiBusy(true);
|
|
1309
|
+
setExtapiError("");
|
|
1310
|
+
try {
|
|
1311
|
+
const res = await apiFetch("/api/dsh-mneme/external-api", {
|
|
1312
|
+
method: "PUT",
|
|
1313
|
+
headers: { "Content-Type": "application/json" },
|
|
1314
|
+
body: JSON.stringify(body)
|
|
1315
|
+
});
|
|
1316
|
+
const data = await res.json().catch(() => ({}));
|
|
1317
|
+
if (!res.ok) throw new Error(data.error || "HTTP " + res.status);
|
|
1318
|
+
const cfg = data.config || {};
|
|
1319
|
+
const next = {
|
|
1320
|
+
enabled: !!cfg.enabled,
|
|
1321
|
+
host: cfg.host || "127.0.0.1",
|
|
1322
|
+
port: Number(cfg.port) || 0,
|
|
1323
|
+
token: cfg.token || ""
|
|
1324
|
+
};
|
|
1325
|
+
setExtapi(next);
|
|
1326
|
+
setExtapiHost(next.host);
|
|
1327
|
+
setExtapiPort(next.port ? String(next.port) : "");
|
|
1328
|
+
setExtapiSaved(true);
|
|
1329
|
+
setTimeout(() => setExtapiSaved(false), 2500);
|
|
1330
|
+
} catch (err) {
|
|
1331
|
+
setExtapiError((err && err.message) || "failed");
|
|
1332
|
+
}
|
|
1333
|
+
setExtapiBusy(false);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function saveExtapiEnabled(enabled) {
|
|
1337
|
+
if (extapiBusy) return;
|
|
1338
|
+
putExtapi({ enabled });
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function saveExtapiAddress() {
|
|
1342
|
+
if (extapiBusy) return;
|
|
1343
|
+
const raw = String(extapiPort).trim();
|
|
1344
|
+
const port = Number(raw);
|
|
1345
|
+
if (!/^\d+$/.test(raw) || port < 1 || port > 65535) {
|
|
1346
|
+
setExtapiError(t("memory.settings.extapi.invalidPort"));
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
putExtapi({ port, host: extapiHost.trim() || "127.0.0.1" });
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function copyExtapiToken() {
|
|
1353
|
+
const token = extapi ? extapi.token || "" : "";
|
|
1354
|
+
navigator.clipboard?.writeText(token).then(
|
|
1355
|
+
() => { setExtapiCopied(true); setTimeout(() => setExtapiCopied(false), 1500); },
|
|
1356
|
+
() => {}
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1198
1360
|
return h("div", null,
|
|
1199
1361
|
// 用户画像 — who the agent is talking to
|
|
1200
1362
|
h("section", { className: "mneme-set-sec" },
|
|
@@ -1271,6 +1433,86 @@ window.__ModuleLoader__.load({
|
|
|
1271
1433
|
)
|
|
1272
1434
|
)
|
|
1273
1435
|
),
|
|
1436
|
+
// 运行模式 — light vs standard chip radios; each click PUTs and the
|
|
1437
|
+
// change only lands after a DSH restart (green saved hint says so).
|
|
1438
|
+
h("section", { className: "mneme-set-card" },
|
|
1439
|
+
h("div", { className: "mneme-set-title" }, t("memory.settings.mode.title")),
|
|
1440
|
+
h("div", { className: "mneme-set-desc" }, t("memory.settings.mode.desc")),
|
|
1441
|
+
modeError && h("div", { style: { fontSize: 12, color: "var(--dsw-alias-state-error,#c33)", marginBottom: 8 } }, modeError),
|
|
1442
|
+
mode === null && !modeError
|
|
1443
|
+
? h("div", { className: "mneme-set-hint" }, "…")
|
|
1444
|
+
: h(react.Fragment, null,
|
|
1445
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" } },
|
|
1446
|
+
h("button", {
|
|
1447
|
+
className: mode === "light" ? "mneme-chip mneme-active" : "mneme-chip",
|
|
1448
|
+
disabled: modeBusy,
|
|
1449
|
+
onClick: () => saveMode("light")
|
|
1450
|
+
}, t("memory.settings.mode.light")),
|
|
1451
|
+
h("button", {
|
|
1452
|
+
className: mode === "standard" ? "mneme-chip mneme-active" : "mneme-chip",
|
|
1453
|
+
disabled: modeBusy,
|
|
1454
|
+
onClick: () => saveMode("standard")
|
|
1455
|
+
}, t("memory.settings.mode.standard")),
|
|
1456
|
+
modeSaved && h("span", { className: "mneme-saved" }, t("memory.settings.mode.savedHint"))
|
|
1457
|
+
),
|
|
1458
|
+
mode === "light" && h("div", { className: "mneme-set-hint", style: { marginTop: 8 } },
|
|
1459
|
+
t("memory.settings.mode.offList"))
|
|
1460
|
+
)
|
|
1461
|
+
),
|
|
1462
|
+
// 外部访问 API — standalone HTTP service for plugins/CLI/desktop tools;
|
|
1463
|
+
// the token is generated and kept by the backend, so it is read-only
|
|
1464
|
+
// here with a copy affordance. Changes need a DSH restart.
|
|
1465
|
+
h("section", { className: "mneme-set-card" },
|
|
1466
|
+
h("div", { className: "mneme-set-title" }, t("memory.settings.extapi.title")),
|
|
1467
|
+
h("div", { className: "mneme-set-desc" }, t("memory.settings.extapi.desc")),
|
|
1468
|
+
extapiError && h("div", { style: { fontSize: 12, color: "var(--dsw-alias-state-error,#c33)", marginBottom: 8 } }, extapiError),
|
|
1469
|
+
extapi === null && !extapiError
|
|
1470
|
+
? h("div", { className: "mneme-set-hint" }, "…")
|
|
1471
|
+
: h(react.Fragment, null,
|
|
1472
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" } },
|
|
1473
|
+
h("button", {
|
|
1474
|
+
className: extapi && extapi.enabled ? "mneme-chip mneme-active" : "mneme-chip",
|
|
1475
|
+
disabled: extapiBusy,
|
|
1476
|
+
onClick: () => saveExtapiEnabled(true)
|
|
1477
|
+
}, t("memory.settings.extapi.enabled")),
|
|
1478
|
+
h("button", {
|
|
1479
|
+
className: extapi && !extapi.enabled ? "mneme-chip mneme-active" : "mneme-chip",
|
|
1480
|
+
disabled: extapiBusy,
|
|
1481
|
+
onClick: () => saveExtapiEnabled(false)
|
|
1482
|
+
}, t("memory.settings.extapi.disabled")),
|
|
1483
|
+
extapiSaved && h("span", { className: "mneme-saved" }, t("memory.settings.extapi.savedHint"))
|
|
1484
|
+
),
|
|
1485
|
+
extapi && extapi.enabled && h(react.Fragment, null,
|
|
1486
|
+
h("div", { className: "mneme-set-hint", style: { marginTop: 10 } },
|
|
1487
|
+
`${t("memory.settings.extapi.address")}: http://${extapi.host}:${extapi.port}`),
|
|
1488
|
+
h("div", { style: { display: "flex", gap: 8, marginTop: 8 } },
|
|
1489
|
+
h("input", {
|
|
1490
|
+
className: "mneme-set-input",
|
|
1491
|
+
style: { marginBottom: 0, flex: 2, minWidth: 0, width: "auto" },
|
|
1492
|
+
value: extapiHost,
|
|
1493
|
+
placeholder: "127.0.0.1",
|
|
1494
|
+
onChange: (e) => setExtapiHost(e.target.value)
|
|
1495
|
+
}),
|
|
1496
|
+
h("input", {
|
|
1497
|
+
className: "mneme-set-input",
|
|
1498
|
+
style: { marginBottom: 0, flex: 1, minWidth: 0, width: "auto" },
|
|
1499
|
+
value: extapiPort,
|
|
1500
|
+
placeholder: t("memory.settings.extapi.port"),
|
|
1501
|
+
inputMode: "numeric",
|
|
1502
|
+
onChange: (e) => setExtapiPort(e.target.value)
|
|
1503
|
+
}),
|
|
1504
|
+
h("button", { className: "mneme-btn", disabled: extapiBusy, onClick: saveExtapiAddress },
|
|
1505
|
+
t("memory.settings.vectorSave"))
|
|
1506
|
+
),
|
|
1507
|
+
h("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 10 } },
|
|
1508
|
+
h("div", { className: "mneme-set-token", style: { flex: 1, minWidth: 0 } }, extapi.token || "—"),
|
|
1509
|
+
h("button", { className: "mneme-btn", onClick: copyExtapiToken },
|
|
1510
|
+
t("memory.settings.extapi.copy")),
|
|
1511
|
+
extapiCopied && h("span", { className: "mneme-saved" }, t("memory.settings.extapi.copied"))
|
|
1512
|
+
)
|
|
1513
|
+
)
|
|
1514
|
+
)
|
|
1515
|
+
),
|
|
1274
1516
|
// 向量搜索 — semantic recall over an embeddings API
|
|
1275
1517
|
h("section", { className: "mneme-set-sec" },
|
|
1276
1518
|
h("div", { className: "mneme-set-title" }, t("memory.settings.vectorTitle")),
|