DeepSeek Harness 本地聊天记忆:按来源保存聊天原文,关键词 + 向量检索,可选大模型重排与检索摘要。源码公开,仅限非商用
浏览文件
main
分支
client.js
145 行 · 12.9 KB历史下载| 1 | /* DSH supplies React; this file has no CDN or third-party browser dependency. */ |
| 2 | window.__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 | } }); |

