跳到主要内容

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

浏览文件
main

分支

client.js

145 行 · 12.9 KB历史下载
1/* DSH supplies React; this file has no CDN or third-party browser dependency. */
2window.__ModuleLoader__.load({ id: 'dsh-companion-memory', factory(require) {
3 const React = require('react');
4 const h = React.createElement;
5 const style = {
6 panel: { padding: 24, maxWidth: 860, color: 'var(--dsw-alias-label-primary)' },
7 field: { display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 },
8 control: { padding: '8px 10px', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l4)',
9 background: 'var(--dsw-alias-background-primary)', color: 'var(--dsw-alias-label-primary)' },
10 card: { padding: 16, margin: '14px 0', borderRadius: 12, border: '1px solid var(--dsw-alias-border-l4)' },
11 row: { display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center', margin: '12px 0' },
12 muted: { color: 'var(--dsw-alias-label-secondary)', lineHeight: 1.6 },
13 pre: { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', maxHeight: 440, overflow: 'auto' },
14 };
15 function Settings() {
16 const [state, setState] = React.useState(null);
17 const [settings, setSettings] = React.useState(null);
18 const [selection, setSelection] = React.useState('');
19 const [query, setQuery] = React.useState('');
20 const [hits, setHits] = React.useState([]);
21 const [source, setSource] = React.useState(null);
22 const [notice, setNotice] = React.useState('');
23 const [busy, setBusy] = React.useState(false);
24 const token = React.useRef('');
25 const live = React.useRef(true);
26 async function api(path, body) {
27 const response = await fetch('/dsh-companion-memory/' + path, { cache: 'no-store',
28 method: body === undefined ? 'GET' : 'POST',
29 headers: body === undefined ? undefined : { 'content-type': 'application/json', 'x-companion-memory-token': token.current },
30 body: body === undefined ? undefined : JSON.stringify(body),
31 });
32 const data = await response.json();
33 if (!response.ok) throw new Error(data.error || '操作失败');
34 return data;
35 }
36 async function refresh(resetForm = false) {
37 const data = await api('state'); if (!live.current) return;
38 token.current = data.csrf; setState(data);
39 if (resetForm) setSettings(data.settings);
40 }
41 React.useEffect(() => {
42 live.current = true;
43 refresh(true).catch(() => { if (live.current) setNotice('无法连接记忆插件,请确认主机插件已启用。'); });
44 return () => { live.current = false; };
45 }, []);
46 async function run(task) {
47 setBusy(true); setNotice('');
48 try { await task(); } catch (error) { if (live.current) setNotice(error.message); }
49 finally { if (live.current) setBusy(false); }
50 }
51 const patch = (key, value) => setSettings(old => ({ ...old, [key]: value }));
52 const button = (label, onClick, disabled = false) => h('button', { type: 'button', disabled: busy || disabled, onClick, style: style.control }, label);
53 const check = (label, checked, onChange) => h('label', { style: style.row }, h('input', { type: 'checkbox', checked, onChange: event => onChange(event.target.checked) }), label);
54 const field = (label, value, onChange, type = 'text') => h('label', { style: style.field }, label,
55 h('input', { type, value, onChange: event => onChange(type === 'number' ? Number(event.target.value) : event.target.value), style: style.control }));
56 const provider = (key, title, protocols) => {
57 const value = settings[key];
58 const update = (name, next) => patch(key, { ...value, [name]: next, ...(name === 'baseUrl' ? { allowRemote: false } : {}) });
59 return h('fieldset', { style: style.card }, h('legend', null, title),
60 check('启用', value.enabled, next => update('enabled', next)),
61 h('label', { style: style.field }, '协议', h('select', { value: value.protocol, style: style.control, onChange: event => update('protocol', event.target.value) },
62 protocols.map(protocol => h('option', { key: protocol, value: protocol }, protocol)))),
63 field('服务基础地址', value.baseUrl, next => update('baseUrl', next)),
64 field('模型名称', value.model, next => update('model', next)),
65 field('模型修订号(更换权重或维度时更新)', value.revision, next => update('revision', next)),
66 field('密钥环境变量名(例如 MEMORY_API_KEY;不要填密钥本身)', value.apiKeyEnv, next => update('apiKeyEnv', next)),
67 field('单次请求超时 / 毫秒', value.timeoutMs, next => update('timeoutMs', next), 'number'),
68 check('我允许向此远程服务发送查询和对应的聊天文本', value.allowRemote, next => update('allowRemote', next)));
69 };
70 const listField = (label, value, onChange) => field(label, (value || []).join(', '), next => onChange(next.split(/[,,\s]+/).filter(Boolean)));
71 const scopeLabel = scope => scope === 'owner' ? '主人' : scope.startsWith('group:') ? '群 ' + scope.slice(6)
72 : scope.startsWith('private:') ? '私聊 ' + scope.slice(8) : scope === 'shared' ? '旧共享' : '未识别会话 ' + scope.slice(8, 16);
73 const [rulesText, setRulesText] = React.useState('');
74 React.useEffect(() => { if (settings) setRulesText(JSON.stringify(settings.originRules, null, 2)); }, [settings === null]);
75 const access = selection ? { scope: selection } : null;
76 return h('main', { style: style.panel },
77 h('h2', null, '聊天记忆'),
78 h('p', { style: style.muted }, '本地保存聊天原文,回答前检索。按来源(主人 / 群 / 私聊)分范围,换会话不丢;不需要千问或其他指定模型。'),
79 h('p', { role: 'status', style: style.muted }, busy ? '处理中…' : notice),
80 state && h('p', { style: style.muted }, `原文 ${state.stats.records} 条 · 片段 ${state.stats.chunks} 个 · 向量 ${state.stats.vectors} 个 · 待索引 ${state.stats.pending} 个`),
81 state && h('p', { style: style.muted }, '数据目录:', h('code', null, state.dir)),
82 settings && h(React.Fragment, null,
83 h('p', { style: { ...style.card, ...style.muted } }, '不会配也没关系:直接对你的鱼说「帮我把聊天记忆设置好,我的 QQ 号是 ……」,她会自己配置,DSH 会弹窗请你批准。'),
84 check('自动记录聊天', settings.autoRecord, value => patch('autoRecord', value)),
85 check('回答前自动想起相关的事', settings.autoRetrieve, value => patch('autoRetrieve', value)),
86 listField('主人的 QQ 号(可不填,逗号分隔)', settings.ownerIds, value => patch('ownerIds', value)),
87 button('保存', () => run(async () => { await api('settings', settings); await refresh(true); setNotice('已保存'); })),
88 h('details', { style: style.card }, h('summary', null, '高级设置'),
89 check('给模型提供翻记忆 / 记一笔 / 删除工具', settings.tools, value => patch('tools', value)),
90 h('fieldset', { style: style.card }, h('legend', null, '来源与可见范围'),
91 h('label', { style: style.field }, '认不出来源的会话', h('select', { value: settings.unknownOrigin, style: style.control, onChange: event => patch('unknownOrigin', event.target.value) },
92 h('option', { value: 'isolated' }, '单独隔离(推荐;安全)'),
93 h('option', { value: 'owner' }, '当作主人(只有你自己用 DSH 桌面端时)'))),
94 check('群聊内容在其他群和私聊里也能被想起', settings.shareGroups, value => patch('shareGroups', value)),
95 check('主人的内容在群和私聊里也能被想起', settings.shareOwner, value => patch('shareOwner', value)),
96 h('p', { style: style.muted }, '别人的私聊永远只在和他的私聊里可见。设置只影响之后的新轮次,已有记录保持原范围。'),
97 h('details', null, h('summary', null, '高级:会话 ID 识别规则(JSON)'),
98 h('p', { style: style.muted }, '按会话 ID 认出群号 / QQ 号;只读会话 ID,不读消息内容。桥接也可以调用 api/bind 直接告诉插件。'),
99 h('textarea', { value: rulesText, rows: 8, style: { ...style.control, width: '100%', fontFamily: 'monospace' }, onChange: event => {
100 setRulesText(event.target.value);
101 try { patch('originRules', JSON.parse(event.target.value)); } catch {}
102 } })),
103 listField('算作「她在说话」的工具(* 通配,逗号分隔)', settings.speakTools, value => patch('speakTools', value)),
104 listField('算作「她读到的消息」的工具(* 通配,逗号分隔)', settings.hearTools, value => patch('hearTools', value))),
105 field('每次最多检索片段数(1–20)', settings.topK, value => patch('topK', value), 'number'),
106 field('注入上下文字符预算(300–20000)', settings.maxContextChars, value => patch('maxContextChars', value), 'number'),
107 provider('embedding', '向量模型(可选)', ['openai', 'ollama']),
108 provider('reranker', '重排模型(可选;chat=用 DeepSeek 等聊天模型打分,地址填 https://api.deepseek.com,模型填 deepseek-flash)', ['generic', 'dashscope', 'chat']),
109 h('div', { style: style.row },
110 button('保存高级设置', () => run(async () => { await api('settings', settings); await refresh(true); setNotice('已保存'); })),
111 button('补全向量索引', () => run(async () => { const result = await api('index', {}); await refresh(); setNotice(result.disabled ? '请先启用向量模型' : result.busy ? '索引正在运行,请稍后重试' : `已索引 ${result.indexed} 个片段`); })))),
112 h('details', { style: style.card }, h('summary', null, '查看与管理记忆'),
113 h('div', { style: style.row }, button('刷新记录', () => run(() => refresh())),
114 h('select', { style: style.control, value: selection, onChange: event => { setSelection(event.target.value); setHits([]); setSource(null); } },
115 h('option', { value: '' }, '选择记忆范围'),
116 state.collections.map(item => h('option', { key: item.scope, value: item.scope }, `${scopeLabel(item.scope)} · ${item.records} 条`)))),
117 field('检索内容', query, setQuery),
118 h('div', { style: style.row },
119 button('检索', () => run(async () => { const data = await api('search', { ...access, query }); setHits(data.hits); setSource(null); if (!data.hits.length) setNotice('没有找到相关原文'); }), !access || !query),
120 button('导出所选范围', () => run(async () => {
121 const data = await api('export', access);
122 const blob = new Blob([data.records.map(row => JSON.stringify({ sourceKey: row.source_key, createdAt: row.created_at, content: row.content, source: row.source })).join('\n')], { type: 'application/x-ndjson' });
123 const url = URL.createObjectURL(blob); const anchor = document.createElement('a');
124 anchor.href = url; anchor.download = 'companion-memory.jsonl'; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
125 }), !access)),
126 hits.map(hit => h('article', { key: hit.id, style: style.card },
127 h('p', { style: style.muted }, `#${hit.record_id}/${hit.ordinal} · ${new Date(hit.created_at).toLocaleString()}`),
128 h('p', { style: style.pre }, hit.content),
129 h('div', { style: style.row },
130 button('查看整条原文', () => run(async () => { const data = await api('source', { ...access, id: hit.record_id }); setSource(data.record); })),
131 button('删除这条记忆', () => {
132 if (!window.confirm('删除本插件内的整条原文和索引,并阻止自动重新导入?DSH 原始日志不会删除。')) return;
133 run(async () => { await api('forget', { ...access, id: hit.record_id }); setHits(old => old.filter(row => row.record_id !== hit.record_id)); setSource(null); await refresh(); setNotice('已删除派生记忆'); });
134 })))),
135 source && h('section', { style: style.card }, h('h3', null, '原文来源'),
136 h('pre', { style: style.pre }, source.source), h('pre', { style: style.pre }, source.content))),
137 h('details', { style: style.card }, h('summary', null, '桥接接口(给 QQ 桥接或其他本机程序用)'),
138 h('p', { style: style.muted }, '只接受本机请求。请求头 Authorization: Bearer 加下面的令牌;接口:/dsh-companion-memory/api/ingest、api/search、api/bind,说明见 README。令牌等同于记忆的读写权限,不要发给别人。'),
139 h('pre', { style: style.pre }, state.apiToken))),
140 h('p', { style: style.muted }, '删除只清理本插件的记录、索引和自动导入来源。DSH 原始会话、备份和磁盘残留仍需由用户另行管理。历史原文是参考证据,助手说过的话不自动成为事实。'));
141 }
142 return { inject: ['slots'], apply(ctx) {
143 ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({ name: 'settings.plugins.tab', id: 'dsh-companion-memory', order: 56, label: () => '聊天记忆' }, Settings));
144 } };
145} });