跳到主要内容

DeepSeek Harness 本地聊天记忆:按来源保存聊天原文,关键词 + 向量检索,可选大模型重排与检索摘要。源码公开,仅限非商用

浏览文件
main

分支

management.js

108 行 · 6.8 KB历史下载
1import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
2import { recall } from './recall.js';
3import { UserError } from './errors.js';
4
5const PREFIX = '/dsh-companion-memory/';
6const reply = (res, status, body) => { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }); res.end(JSON.stringify(body)); };
7async function readBody(req) {
8 if (!/^application\/json(?:;|$)/i.test(req.headers['content-type'] || '')) throw new UserError('需要 JSON');
9 let bytes = 0; const parts = [];
10 for await (const chunk of req) { bytes += Buffer.byteLength(chunk); if (bytes > 65536) throw new UserError('请求过大'); parts.push(Buffer.from(chunk)); }
11 let value;
12 try { value = JSON.parse(Buffer.concat(parts).toString('utf8')); } catch { throw new UserError('JSON 格式错误'); }
13 if (!value || typeof value !== 'object' || Array.isArray(value)) throw new UserError('无效请求');
14 return value;
15}
16const same = (given, expected) => {
17 const a = Buffer.from(String(given || '')), b = Buffer.from(expected);
18 return a.length === b.length && timingSafeEqual(a, b);
19};
20const failure = (res, error) => reply(res, 400, { error: error instanceof UserError ? error.message : '操作失败,请检查配置或输入;日志不包含原文和密钥。' });
21
22export function authorized(ctx, req) {
23 try {
24 const connection = ctx.connection;
25 return typeof connection?.requestRejection === 'function' && connection.requestRejection(req) === undefined;
26 } catch { return false; }
27}
28const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
29export const isLoopback = req => LOOPBACK.has(req.socket?.remoteAddress);
30
31/** Browser settings page: DSH's own browser authentication plus a per-process CSRF token. */
32function browserRoutes(service) {
33 const csrf = randomBytes(32).toString('hex');
34 const scopeOf = value => {
35 const scope = String(value ?? '');
36 if (!service.collections().some(row => row.scope === scope)) throw new UserError('请选择已有记忆的范围');
37 return service.access(scope);
38 };
39 const handlers = {
40 state: () => ({ settings: service.settings(), stats: service.memory.stats(), collections: service.collections(), dir: service.dir, csrf, apiToken: service.apiToken }),
41 settings: body => ({ settings: service.save(body) }),
42 index: () => service.index(),
43 search: async body => ({ hits: await service.memory.search(String(body.query ?? ''), { ...scopeOf(body.scope), signal: service.signal() }) }),
44 source: body => ({ record: service.memory.get(body.id, scopeOf(body.scope)) }),
45 forget: body => ({ forgotten: service.memory.forget(body.id, scopeOf(body.scope)) }),
46 export: body => ({ records: service.memory.export(scopeOf(body.scope)) }),
47 };
48 return Object.entries(handlers).map(([name, handle]) => ({ path: PREFIX + name, async handler(req, res, scoped) {
49 if (!authorized(scoped, req)) return reply(res, 403, { error: 'authentication-required' });
50 if (name === 'state') return req.method === 'GET' ? reply(res, 200, handle()) : reply(res, 405, { error: 'method' });
51 if (req.method !== 'POST') return reply(res, 405, { error: 'method' });
52 if (!same(req.headers['x-companion-memory-token'], csrf)) return reply(res, 403, { error: 'csrf' });
53 if (req.headers['sec-fetch-site'] === 'cross-site') return reply(res, 403, { error: 'origin' });
54 try { reply(res, 200, await handle(await readBody(req))); } catch (error) { failure(res, error); }
55 } }));
56}
57
58/**
59 * Open interface for bridges and other local programs (no DSH coupling required):
60 * POST api/ingest { origin, text, speaker?, sourceKey?, time? } store a message
61 * POST api/search { origin, query, limit? } retrieve as that origin would see
62 * POST api/bind { sessionId, origin } tell the plugin where a DSH session comes from
63 * Auth: `Authorization: Bearer <api-token>` from loopback only.
64 */
65function apiRoutes(service) {
66 const handlers = {
67 ingest(body) {
68 const origin = service.normalizeOrigin(body.origin);
69 if (typeof body.text !== 'string' || !body.text.trim() || body.text.length > 20000) throw new UserError('text 需为 1–20000 字符');
70 const speaker = typeof body.speaker === 'string' ? body.speaker.trim().slice(0, 40) : '';
71 const key = body.sourceKey === undefined ? randomUUID() : String(body.sourceKey);
72 if (!key || key.length > 200) throw new UserError('sourceKey 需为 1–200 字符');
73 const createdAt = body.time === undefined ? Date.now() : Number(body.time);
74 if (!Number.isSafeInteger(createdAt) || createdAt < 1) throw new UserError('time 需为毫秒时间戳');
75 const result = service.note(origin, { sourceKey: 'api:' + key, createdAt, text: speaker ? `${speaker}:${body.text.trim()}` : body.text.trim(),
76 source: { platform: 'api', ...(speaker ? { speaker } : {}) } });
77 return result.skipped ? { skipped: result.skipped } : { id: result.id, unchanged: Boolean(result.unchanged) };
78 },
79 async search(body) {
80 const origin = service.normalizeOrigin(body.origin);
81 if (typeof body.query !== 'string' || !body.query.trim()) throw new UserError('query 不能为空');
82 const topK = Number.isInteger(body.limit) ? Math.min(20, Math.max(1, body.limit)) : service.settings().topK;
83 const { hits, text } = await recall(service.memory, body.query, service.view(origin),
84 { topK, maxChars: service.settings().maxContextChars, signal: service.signal(), here: origin });
85 return { hits: hits.map(hit => ({ id: hit.record_id, scope: hit.scope, createdAt: hit.created_at, content: hit.content })), evidence: text };
86 },
87 bind(body) {
88 service.memory.bind(String(body.sessionId ?? ''), service.normalizeOrigin(body.origin));
89 return { ok: true };
90 },
91 };
92 return Object.entries(handlers).map(([name, handle]) => ({ path: PREFIX + 'api/' + name, async handler(req, res) {
93 if (!isLoopback(req)) return reply(res, 403, { error: 'loopback-only' });
94 if (!same(String(req.headers.authorization || '').replace(/^Bearer\s+/i, ''), service.apiToken)) return reply(res, 401, { error: 'token' });
95 if (req.method !== 'POST') return reply(res, 405, { error: 'method' });
96 try { reply(res, 200, await handle(await readBody(req))); } catch (error) { failure(res, error); }
97 } }));
98}
99
100export function registerManagement(ctx, service) {
101 const mount = (names, routes) => ctx.inject(names, scoped => {
102 for (const route of routes) scoped.effect(() => scoped.webServer.register({ kind: 'exact', path: route.path,
103 handler: (req, res) => route.handler(req, res, scoped) }));
104 });
105 // The settings page relies on DSH's browser authentication; the bridge API on its own local token.
106 mount(['webServer', 'connection'], browserRoutes(service));
107 mount(['webServer'], apiRoutes(service));
108}