DeepSeek Harness 本地聊天记忆:按来源保存聊天原文,关键词 + 向量检索,可选大模型重排与检索摘要。源码公开,仅限非商用
浏览文件
main
分支
management.js
108 行 · 6.8 KB历史下载| 1 | import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; |
| 2 | import { recall } from './recall.js'; |
| 3 | import { UserError } from './errors.js'; |
| 4 | |
| 5 | const PREFIX = '/dsh-companion-memory/'; |
| 6 | const 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)); }; |
| 7 | async 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 | } |
| 16 | const same = (given, expected) => { |
| 17 | const a = Buffer.from(String(given || '')), b = Buffer.from(expected); |
| 18 | return a.length === b.length && timingSafeEqual(a, b); |
| 19 | }; |
| 20 | const failure = (res, error) => reply(res, 400, { error: error instanceof UserError ? error.message : '操作失败,请检查配置或输入;日志不包含原文和密钥。' }); |
| 21 | |
| 22 | export 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 | } |
| 28 | const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']); |
| 29 | export const isLoopback = req => LOOPBACK.has(req.socket?.remoteAddress); |
| 30 | |
| 31 | /** Browser settings page: DSH's own browser authentication plus a per-process CSRF token. */ |
| 32 | function 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 | */ |
| 65 | function 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 | |
| 100 | export 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 | } |

