dsh-line-bot 0.6.1 → 0.7.1
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/CHANGELOG.md +12 -0
- package/README.en.md +2 -0
- package/README.ja.md +2 -0
- package/README.ko.md +2 -0
- package/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/client.js +43 -11
- package/lib/chat-import.mjs +172 -0
- package/lib/config.mjs +1 -0
- package/lib/host.mjs +186 -14
- package/lib/memory.mjs +18 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.1
|
|
4
|
+
|
|
5
|
+
- 群組卡片新增「匯入聊天」:選 LINE 匯出的 `.txt`,自動整理群組摘要、常聊主題與各成員的記憶點;比較晚加入群組也能補上背景。
|
|
6
|
+
- 匯入的成員記憶存成 `群組ID:name:名字`(已知帳號則用 LINE userId),會出現在面板「個人記憶」;群組回覆會帶入匯入摘要。
|
|
7
|
+
- 匯入結果只存在本機 `state.json` 與 `memories.json`。
|
|
8
|
+
|
|
9
|
+
## 0.7.0
|
|
10
|
+
|
|
11
|
+
- 群組內每位成員有自己的記憶點(同一個人到不同群組也分開),回覆時會注入該成員的記憶。
|
|
12
|
+
- 群組回覆會參考最近 4~6 則別人的訊息;沒被點名而擲骰不回時,訊息仍會留給下一輪參考。
|
|
13
|
+
- 群組「清空記憶」會同時清掉該群組所有成員的記憶。
|
|
14
|
+
|
|
3
15
|
## 0.6.1
|
|
4
16
|
|
|
5
17
|
- 修正模型用 `<meme#18>` 這種角度括號標記時,圖片沒送出、反而把標記當文字顯示的問題;現在支援 `<meme#N>`、`[[meme:#N]]`、`[[meme:N]]` 與 `[表情: …]`,並會清掉殘留的括號。
|
package/README.en.md
CHANGED
|
@@ -142,6 +142,8 @@ When the bot is added to a group (or a multi-user room):
|
|
|
142
142
|
- **Being @-mentioned always triggers a reply**, and **quoting any message always triggers a reply**; any other message is answered only when a dice roll against the *group reply chance* (default 15%) succeeds, otherwise it stays quiet.
|
|
143
143
|
- Each group gets its own **DSH session, workspace folder, persona and personal memory**, separate from direct chats and other groups.
|
|
144
144
|
- The `persona` command works in groups too; the panel's **Groups** card lets you tune the reply chance, toggles, per-group persona and clear a group's memory.
|
|
145
|
+
- Each member has **their own memory points** inside a group; replies also look at the last 4–6 messages from others so the bot can pick up the thread.
|
|
146
|
+
- If you join a group late, use **Import chat** on the Groups card and pick a LINE-exported `.txt`; it builds a group summary and per-member memory points (stored locally only).
|
|
145
147
|
- Proactive messages and DeepSeek balance reminders still target direct chats only.
|
|
146
148
|
|
|
147
149
|
## Per-user memory and personas
|
package/README.ja.md
CHANGED
|
@@ -142,6 +142,8 @@ Bot がグループ(または複数人のルーム)に追加されたとき
|
|
|
142
142
|
- **@ で呼ばれたら必ず返信**、**引用(返信)されたら必ず返信**します。それ以外は「グループ返信確率」(既定 15%)の抽選に当たったときだけ返信します。
|
|
143
143
|
- グループごとに **DSH セッション・ワークスペース・ペルソナ・個人メモリ** を持ち、1 対 1 のチャットや他のグループとは分かれています。
|
|
144
144
|
- グループでも `人設` コマンドが使えます。パネルの「グループ」カードで返信確率・スイッチ・グループごとのペルソナ・メモリの消去を設定できます。
|
|
145
|
+
- グループ内でも**メンバーごとに別々の記憶**を持ちます。返信時は直近 4~6 件の他人のメッセージも参考にして話を拾います。
|
|
146
|
+
- グループに後から参加した場合は、パネルの「グループ」カードで **チャットを読み込む** を押し、LINE が書き出した `.txt` を選ぶと、グループ要約とメンバーごとの記憶ポイントを作ります(保存はローカルのみ)。
|
|
145
147
|
- 自発メッセージと DeepSeek 残高の通知は 1 対 1 の相手にだけ送られます。
|
|
146
148
|
|
|
147
149
|
## ユーザーごとのメモリとペルソナ
|
package/README.ko.md
CHANGED
|
@@ -142,6 +142,8 @@ Bot이 그룹(또는 다인 룸)에 추가되면:
|
|
|
142
142
|
- **@로 호출되면 항상 답장**하고, **인용(답장)되면 항상 답장**합니다. 그 외에는 "그룹 답장 확률"(기본 15%) 주사위를 굴려 당첨될 때만 답장합니다.
|
|
143
143
|
- 그룹마다 **DSH 세션, 작업 폴더, 페르소나, 개인 기억**이 따로 있으며 1:1 채팅이나 다른 그룹과 분리됩니다.
|
|
144
144
|
- 그룹에서도 `人設` 명령을 쓸 수 있습니다. 패널의 "그룹" 카드에서 답장 확률, 스위치, 그룹별 페르소나, 기억 지우기를 설정할 수 있습니다.
|
|
145
|
+
- 그룹 안에서도 **멤버마다 별도의 기억**을 가집니다. 답장할 때 최근 4~6개의 다른 사람 메시지도 참고해 대화를 이어갑니다.
|
|
146
|
+
- 그룹에 늦게 참여했다면 패널의 “그룹” 카드에서 **채팅 가져오기**를 눌러 LINE에서 내보낸 `.txt`를 선택하세요. 그룹 요약과 멤버별 기억 포인트를 만들어 줍니다(로컬에만 저장).
|
|
145
147
|
- 자발적 메시지와 DeepSeek 잔액 알림은 여전히 1:1 대상에게만 갑니다.
|
|
146
148
|
|
|
147
149
|
## 사용자별 기억과 페르소나
|
package/README.md
CHANGED
|
@@ -142,6 +142,8 @@ Bot 被加進群組(或多人聊天室)後:
|
|
|
142
142
|
- **被 @ 一定回**、**被引用(回覆某則訊息)一定回**;其他訊息依「群組回覆機率」擲骰(預設 15%),沒中就安靜。
|
|
143
143
|
- 每個群組有自己的 **DSH session、工作資料夾、人設與個人記憶**,和一對一聊天、其他群組都分開。
|
|
144
144
|
- 群組裡也能用 `人設` 指令;面板「群組」卡可以調回覆機率、開關、個別群組的人設與清空記憶。
|
|
145
|
+
- 每個成員在群組裡有**自己的記憶點**;回覆時也會參考最近 4~6 則別人的訊息,接得上話題。
|
|
146
|
+
- 比較晚加入群組時,可以在面板「群組」卡片按「匯入聊天」,選 LINE 匯出的 `.txt`;會整理出群組摘要與各成員的記憶點(只存本機)。
|
|
145
147
|
- 主動傳訊與 DeepSeek 餘額提醒仍然只找一對一的對象。
|
|
146
148
|
|
|
147
149
|
## 每位使用者各自的記憶與人設
|
package/README.zh-CN.md
CHANGED
|
@@ -142,6 +142,8 @@ Bot 被加入群组(或多人聊天室)后:
|
|
|
142
142
|
- **被 @ 一定回**、**被引用(回复某条消息)一定回**;其他消息按「群组回复概率」掷骰(默认 15%),没中就安静。
|
|
143
143
|
- 每个群组有自己的 **DSH session、工作文件夹、人设与个人记忆**,和一对一聊天、其他群组分开。
|
|
144
144
|
- 群组里也能用 `人设` 指令;面板「群组」卡可调回复概率、开关、个别群组的人设与清空记忆。
|
|
145
|
+
- 每个成员在群组里有**自己的记忆点**;回复时也会参考最近 4~6 条别人的消息,接得上话题。
|
|
146
|
+
- 比较晚加入群组时,可以在面板“群组”卡片按“汇入聊天”,选择 LINE 导出的 `.txt`;会整理出群组摘要与各成员的记忆点(只存本机)。
|
|
145
147
|
- 主动传讯与 DeepSeek 余额提醒仍然只找一对一的对象。
|
|
146
148
|
|
|
147
149
|
## 每位用户各自的记忆与人设
|
package/client.js
CHANGED
|
@@ -5,7 +5,7 @@ window.__ModuleLoader__.load({
|
|
|
5
5
|
factory: (require) => {
|
|
6
6
|
const React = require('react')
|
|
7
7
|
const h = React.createElement
|
|
8
|
-
const { useEffect, useMemo, useState } = React
|
|
8
|
+
const { useEffect, useMemo, useRef, useState } = React
|
|
9
9
|
|
|
10
10
|
const API = '/api/line-bot'
|
|
11
11
|
const SECTION_ID = 'line-bot'
|
|
@@ -356,6 +356,8 @@ window.__ModuleLoader__.load({
|
|
|
356
356
|
const [balancePreview, setBalancePreview] = useState(null)
|
|
357
357
|
const [memoryData, setMemoryData] = useState(null)
|
|
358
358
|
const [modelCatalog, setModelCatalog] = useState(null)
|
|
359
|
+
const [importGroup, setImportGroup] = useState('')
|
|
360
|
+
const importInput = useRef(null)
|
|
359
361
|
const [localEdit, setLocalEdit] = useState({ id: '', name: '', text: '' })
|
|
360
362
|
|
|
361
363
|
const dirty = useMemo(() => {
|
|
@@ -514,6 +516,26 @@ window.__ModuleLoader__.load({
|
|
|
514
516
|
await loadMemory({ quiet: true })
|
|
515
517
|
}
|
|
516
518
|
|
|
519
|
+
async function importChatFile(event) {
|
|
520
|
+
const file = event.target.files && event.target.files[0]
|
|
521
|
+
event.target.value = ''
|
|
522
|
+
if (!file || !importGroup) return
|
|
523
|
+
setBusy('group-import')
|
|
524
|
+
try {
|
|
525
|
+
const text = await file.text()
|
|
526
|
+
const value = await jpost('/action', { action: 'group-import', payload: { groupId: importGroup, text } })
|
|
527
|
+
if (value?.status) { setStatus(value.status); setDraft(stripSecrets(value.status.config || {})); }
|
|
528
|
+
const result = value?.result || {}
|
|
529
|
+
setToast({ kind: 'ok', text: `已匯入 ${result.messageCount || 0} 則聊天,寫入 ${result.memories || 0} 筆成員記憶。` })
|
|
530
|
+
loadMemory({ quiet: true })
|
|
531
|
+
} catch (error) {
|
|
532
|
+
setToast({ kind: 'err', text: error?.message || String(error) })
|
|
533
|
+
} finally {
|
|
534
|
+
setBusy('')
|
|
535
|
+
setImportGroup('')
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
517
539
|
async function loadModelCatalog({ quiet = false, force = false } = {}) {
|
|
518
540
|
if (!quiet) setBusy('model-catalog')
|
|
519
541
|
try {
|
|
@@ -1210,7 +1232,8 @@ window.__ModuleLoader__.load({
|
|
|
1210
1232
|
// 6.5 群組
|
|
1211
1233
|
h(
|
|
1212
1234
|
Card,
|
|
1213
|
-
{ id: sectionDomId('group'), order: 12, title: '群組', desc: '被加進群組時,只有被 @、被引用,或依機率擲骰才回覆;每個群組有自己的 session
|
|
1235
|
+
{ id: sectionDomId('group'), order: 12, title: '群組', desc: '被加進群組時,只有被 @、被引用,或依機率擲骰才回覆;每個群組有自己的 session、工作資料夾、人設與記憶;比較晚加入時可以匯入 LINE 匯出的聊天記錄補記憶。' },
|
|
1236
|
+
h('input', { ref: importInput, type: 'file', accept: '.txt,text/plain', style: { display: 'none' }, onChange: importChatFile }),
|
|
1214
1237
|
h('div', { style: S.grid },
|
|
1215
1238
|
h(Field, { label: '沒被點名時的回覆機率(%)', hint: '每則群組訊息用這個機率決定要不要回;0 就只在被 @ 或引用時回' },
|
|
1216
1239
|
h(Num, { value: Math.round((Number(groupCfg.replyChance) || 0) * 100), min: 0, max: 100, step: 1, onChange: (value) => update({ group: { replyChance: value / 100 } }) })),
|
|
@@ -1226,7 +1249,10 @@ window.__ModuleLoader__.load({
|
|
|
1226
1249
|
h('thead', null, h('tr', null, h('th', { style: S.th }, '群組'), h('th', { style: S.th }, 'session/資料夾'), h('th', { style: S.th }, '訊息'), h('th', { style: S.th }, '人設'), h('th', { style: S.th }, '上次互動'), h('th', { style: S.th }, ''))),
|
|
1227
1250
|
h('tbody', null, groupList.map((group) =>
|
|
1228
1251
|
h('tr', { key: group.groupId },
|
|
1229
|
-
h('td', { style: S.td },
|
|
1252
|
+
h('td', { style: S.td },
|
|
1253
|
+
h('div', null, group.displayName || '(未命名群組)'),
|
|
1254
|
+
h('div', { style: { ...S.hint, ...S.mono } }, group.groupId),
|
|
1255
|
+
group.importedMessages ? h('div', { style: S.hint }, `已匯入 ${group.importedMessages} 則(${fmtTime(group.importedAt)})`) : null),
|
|
1230
1256
|
h('td', { style: { ...S.td, ...S.mono } }, group.sessionId || '—', group.workspaceDir ? h('div', { style: { ...S.hint, ...S.mono, wordBreak: 'break-all' } }, group.workspaceDir) : null),
|
|
1231
1257
|
h('td', { style: S.td }, String(group.messages ?? 0)),
|
|
1232
1258
|
h('td', { style: S.td },
|
|
@@ -1239,14 +1265,20 @@ window.__ModuleLoader__.load({
|
|
|
1239
1265
|
(personaCatalog.length ? personaCatalog : [{ id: group.personaId, name: group.personaName || group.personaId }]).map((item) =>
|
|
1240
1266
|
h('option', { key: item.id, value: item.id }, `${item.name}${item.source === 'local' ? '(自訂)' : ''}`)))),
|
|
1241
1267
|
h('td', { style: S.td }, fmtTime(group.lastAt)),
|
|
1242
|
-
h('td', { style: S.td },
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1268
|
+
h('td', { style: S.td },
|
|
1269
|
+
h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
|
|
1270
|
+
h(Btn, {
|
|
1271
|
+
onClick: () => { setImportGroup(group.groupId); if (importInput.current) importInput.current.click() },
|
|
1272
|
+
disabled: !!busy,
|
|
1273
|
+
}, busy === 'group-import' && importGroup === group.groupId ? '匯入中…' : '匯入聊天'),
|
|
1274
|
+
h(Btn, {
|
|
1275
|
+
onClick: () => {
|
|
1276
|
+
if (typeof window !== 'undefined' && !window.confirm('這會清空這個群組自己的 session 與工作資料夾(記憶、檔案),確定?')) return
|
|
1277
|
+
act('group-reset', { groupId: group.groupId, wipeWorkspace: true })
|
|
1278
|
+
},
|
|
1279
|
+
disabled: !!busy,
|
|
1280
|
+
tone: 'danger',
|
|
1281
|
+
}, '清空記憶')))),
|
|
1250
1282
|
))),
|
|
1251
1283
|
),
|
|
1252
1284
|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
|
|
3
|
+
const TIME_RE = /^(\d{1,2}):(\d{2})\t(.*)$/
|
|
4
|
+
const DATE_RE = /^(\d{4})\/(\d{1,2})\/(\d{1,2})(?:\s*([^)]*))?\s*$/
|
|
5
|
+
const MARKERS = new Set(['[貼圖]', '[照片]', '[檔案]', '[影片]', '[語音訊息]', '[相簿]', '[GIF]', '[表情貼]'])
|
|
6
|
+
|
|
7
|
+
function pad(value) {
|
|
8
|
+
return String(value).padStart(2, '0')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function cleanLine(line) {
|
|
12
|
+
return String(line || '').replace(/[\u2066-\u2069]/g, '')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseLineExport(raw) {
|
|
16
|
+
const lines = cleanLine(raw).replace(/\r\n?/g, '\n').split('\n')
|
|
17
|
+
const messages = []
|
|
18
|
+
let title = ''
|
|
19
|
+
let savedAt = ''
|
|
20
|
+
let date = ''
|
|
21
|
+
let current = null
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
const dateMatch = line.match(DATE_RE)
|
|
24
|
+
if (dateMatch) {
|
|
25
|
+
date = `${dateMatch[1]}-${pad(dateMatch[2])}-${pad(dateMatch[3])}`
|
|
26
|
+
current = null
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
if (!date) {
|
|
30
|
+
if (/^\[LINE\]/.test(line)) title = line.replace(/^\[LINE\]\s*/, '').trim()
|
|
31
|
+
const saved = line.match(/儲存日期[::]\s*(.+)$/)
|
|
32
|
+
if (saved) savedAt = saved[1].trim()
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
const timeMatch = line.match(TIME_RE)
|
|
36
|
+
if (timeMatch) {
|
|
37
|
+
const time = `${pad(timeMatch[1])}:${timeMatch[2]}`
|
|
38
|
+
const rest = timeMatch[3]
|
|
39
|
+
const parts = rest.split('\t')
|
|
40
|
+
if (parts.length >= 2) {
|
|
41
|
+
const name = parts[0].trim()
|
|
42
|
+
const body = parts.slice(1).join('\t')
|
|
43
|
+
if (!name) {
|
|
44
|
+
messages.push({ date, time, name: '', text: body, system: true })
|
|
45
|
+
current = null
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
current = { date, time, name, text: body, system: false }
|
|
49
|
+
messages.push(current)
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
messages.push({ date, time, name: '', text: rest, system: true })
|
|
53
|
+
current = null
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
if (current) current.text += `\n${line}`
|
|
57
|
+
}
|
|
58
|
+
const participants = []
|
|
59
|
+
const counts = new Map()
|
|
60
|
+
for (const message of messages) {
|
|
61
|
+
if (message.system || !message.name) continue
|
|
62
|
+
counts.set(message.name, (counts.get(message.name) || 0) + 1)
|
|
63
|
+
}
|
|
64
|
+
for (const [name, count] of counts) participants.push({ name, count })
|
|
65
|
+
participants.sort((a, b) => b.count - a.count)
|
|
66
|
+
return { title, savedAt, messages, participants }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function clip(value, max = 180) {
|
|
70
|
+
const text = String(value || '').replace(/\s+/g, ' ').trim()
|
|
71
|
+
return text.length > max ? `${text.slice(0, max)}…` : text
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isSubstantive(message) {
|
|
75
|
+
if (message.system || !message.name) return false
|
|
76
|
+
const text = String(message.text || '').trim()
|
|
77
|
+
if (!text) return false
|
|
78
|
+
if (MARKERS.has(text)) return false
|
|
79
|
+
if (/^https?:\/\//i.test(text)) return false
|
|
80
|
+
return text.length >= 4
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function buildDigest(parsed, options = {}) {
|
|
84
|
+
const recentLimit = Math.max(40, Math.min(600, Number(options.recentLimit) || 220))
|
|
85
|
+
const perMember = Math.max(4, Math.min(30, Number(options.perMember) || 12))
|
|
86
|
+
const messages = parsed.messages.filter((message) => !message.system && message.name)
|
|
87
|
+
const substantive = messages.filter(isSubstantive)
|
|
88
|
+
const recent = substantive.slice(-recentLimit)
|
|
89
|
+
const byName = new Map()
|
|
90
|
+
for (const message of substantive) {
|
|
91
|
+
if (!byName.has(message.name)) byName.set(message.name, [])
|
|
92
|
+
byName.get(message.name).push(message)
|
|
93
|
+
}
|
|
94
|
+
const members = []
|
|
95
|
+
for (const [name, list] of byName) {
|
|
96
|
+
members.push({ name, count: list.length, samples: list.slice(-perMember) })
|
|
97
|
+
}
|
|
98
|
+
members.sort((a, b) => b.count - a.count)
|
|
99
|
+
const lines = []
|
|
100
|
+
lines.push(`群組:${parsed.title || '(未命名)'}`)
|
|
101
|
+
if (parsed.savedAt) lines.push(`匯出時間:${parsed.savedAt}`)
|
|
102
|
+
const first = messages[0]
|
|
103
|
+
const last = messages[messages.length - 1]
|
|
104
|
+
if (first && last) lines.push(`訊息範圍:${first.date} ${first.time} ~ ${last.date} ${last.time}`)
|
|
105
|
+
lines.push(`訊息總數:${messages.length}`)
|
|
106
|
+
lines.push(`參與者(發言數):${parsed.participants.map((item) => `${item.name}(${item.count})`).join('、')}`)
|
|
107
|
+
lines.push('')
|
|
108
|
+
lines.push('【最近的對話】')
|
|
109
|
+
for (const message of recent) lines.push(`[${message.date} ${message.time}] ${message.name}:${clip(message.text)}`)
|
|
110
|
+
lines.push('')
|
|
111
|
+
lines.push('【各成員的代表發言】')
|
|
112
|
+
for (const member of members) {
|
|
113
|
+
lines.push(`◆ ${member.name}(共 ${member.count} 則)`)
|
|
114
|
+
for (const message of member.samples) lines.push(`- ${clip(message.text)}`)
|
|
115
|
+
}
|
|
116
|
+
return { text: lines.join('\n'), members, recentCount: recent.length, messageCount: messages.length }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function buildImportPrompt(digest) {
|
|
120
|
+
return [
|
|
121
|
+
'你是一位擅長讀懂群組氣氛的記憶整理助手。下面是一份 LINE 群組聊天記錄摘要。',
|
|
122
|
+
'請整理成嚴格 JSON(不要 markdown 圍欄、不要解釋),格式:',
|
|
123
|
+
'{"summary":"這個群組最近在聊什麼、氣氛如何(120 字內)","topics":["主題1","主題2","主題3"],"members":[{"name":"成員名稱","notes":["關於這個人的長期重點1","重點2"]}]}',
|
|
124
|
+
'規則:',
|
|
125
|
+
'1. members 只放摘要裡真的有出現的成員,名字照抄,不要發明。',
|
|
126
|
+
'2. notes 每人 1~4 點,只寫長期有用的事實、偏好、口頭禪、在意的事或關係;不要寫一次性閒聊、不要評論長相或隱私。',
|
|
127
|
+
'3. 不要記錄電話、地址、帳號、密碼等敏感資料。',
|
|
128
|
+
'4. 看不出來的就少寫,寧缺勿濫。',
|
|
129
|
+
'5. summary 與 notes 用繁體中文。',
|
|
130
|
+
'',
|
|
131
|
+
'聊天記錄摘要:',
|
|
132
|
+
digest,
|
|
133
|
+
].join('\n')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function parseImportResult(text) {
|
|
137
|
+
const raw = String(text || '').trim()
|
|
138
|
+
const start = raw.indexOf('{')
|
|
139
|
+
const end = raw.lastIndexOf('}')
|
|
140
|
+
if (start < 0 || end <= start) return null
|
|
141
|
+
const slice = raw.slice(start, end + 1)
|
|
142
|
+
try {
|
|
143
|
+
const value = JSON.parse(slice)
|
|
144
|
+
if (!value || typeof value !== 'object') return null
|
|
145
|
+
const members = Array.isArray(value.members)
|
|
146
|
+
? value.members
|
|
147
|
+
.filter((item) => item && typeof item.name === 'string' && item.name.trim())
|
|
148
|
+
.map((item) => ({
|
|
149
|
+
name: item.name.trim().slice(0, 40),
|
|
150
|
+
notes: (Array.isArray(item.notes) ? item.notes : [])
|
|
151
|
+
.map((note) => String(note || '').trim().slice(0, 160))
|
|
152
|
+
.filter(Boolean)
|
|
153
|
+
.slice(0, 4),
|
|
154
|
+
}))
|
|
155
|
+
.filter((item) => item.notes.length > 0)
|
|
156
|
+
: []
|
|
157
|
+
return {
|
|
158
|
+
summary: String(value.summary || '').trim().slice(0, 300),
|
|
159
|
+
topics: (Array.isArray(value.topics) ? value.topics : [])
|
|
160
|
+
.map((topic) => String(topic || '').trim().slice(0, 60))
|
|
161
|
+
.filter(Boolean)
|
|
162
|
+
.slice(0, 6),
|
|
163
|
+
members: members.slice(0, 30),
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function readExportFile(file) {
|
|
171
|
+
return fs.readFileSync(file, 'utf8')
|
|
172
|
+
}
|
package/lib/config.mjs
CHANGED
|
@@ -330,6 +330,7 @@ export function normalizeState(raw) {
|
|
|
330
330
|
return {
|
|
331
331
|
users: isPlainObject(state.users) ? state.users : {},
|
|
332
332
|
groups: isPlainObject(state.groups) ? state.groups : {},
|
|
333
|
+
people: isPlainObject(state.people) ? state.people : {},
|
|
333
334
|
nudge: isPlainObject(state.nudge) ? state.nudge : {},
|
|
334
335
|
balance: isPlainObject(state.balance) ? state.balance : {},
|
|
335
336
|
memory: isPlainObject(state.memory) ? state.memory : {},
|
package/lib/host.mjs
CHANGED
|
@@ -18,6 +18,7 @@ const tunnelMod = await import(withVersion('./tunnel.mjs'))
|
|
|
18
18
|
const nudgeMod = await import(withVersion('./nudge.mjs'))
|
|
19
19
|
const tiebaSyncMod = await import(withVersion('./tieba-sync.mjs'))
|
|
20
20
|
const localPersonasMod = await import(withVersion('./local-personas.mjs'))
|
|
21
|
+
const chatImport = await import(withVersion('./chat-import.mjs'))
|
|
21
22
|
const tiebaContextMod = await import(withVersion('./tieba-context.mjs'))
|
|
22
23
|
const balanceMod = await import(withVersion('./deepseek-balance.mjs'))
|
|
23
24
|
const memoryMod = await import(withVersion('./memory.mjs'))
|
|
@@ -41,7 +42,7 @@ const { createMemeSource } = memeSourceMod
|
|
|
41
42
|
const { createStickerImages } = stickerImageMod
|
|
42
43
|
const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody, safeArray, sleep } = utilMod
|
|
43
44
|
|
|
44
|
-
export const VERSION = '0.
|
|
45
|
+
export const VERSION = '0.7.1'
|
|
45
46
|
|
|
46
47
|
// 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
|
|
47
48
|
// 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
|
|
@@ -317,10 +318,10 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
317
318
|
}
|
|
318
319
|
}
|
|
319
320
|
|
|
320
|
-
function agentContextText(
|
|
321
|
+
function agentContextText(ownerKey, kind = 'user', extra = '') {
|
|
321
322
|
// 群組不注入貼吧作息,避免在群組裡突然講起刷貼吧的事。
|
|
322
323
|
const tieba = kind === 'user' ? tiebaContextText() : ''
|
|
323
|
-
return [modelContextText(), tieba, balanceContextText(), memoryContextText(
|
|
324
|
+
return [modelContextText(), tieba, balanceContextText(), extra, memoryContextText(ownerKey), memeContextText() || stickerContextText()].filter(Boolean).join('\n\n')
|
|
324
325
|
}
|
|
325
326
|
|
|
326
327
|
async function refreshBalance() {
|
|
@@ -835,6 +836,142 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
835
836
|
}
|
|
836
837
|
}
|
|
837
838
|
|
|
839
|
+
function personName(userId) {
|
|
840
|
+
const state = store.getState()
|
|
841
|
+
return state.users?.[userId]?.displayName || state.people?.[userId]?.displayName || ''
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
async function ensurePerson(userId) {
|
|
845
|
+
const name = personName(userId)
|
|
846
|
+
if (name) return name
|
|
847
|
+
try {
|
|
848
|
+
const profile = await withToken((token) => lineApi.getProfile(token, userId))
|
|
849
|
+
if (profile?.displayName) {
|
|
850
|
+
store.updateState((state) => ({
|
|
851
|
+
...state,
|
|
852
|
+
people: {
|
|
853
|
+
...(state.people || {}),
|
|
854
|
+
[userId]: { displayName: profile.displayName, pictureUrl: profile.pictureUrl || '', at: Date.now() },
|
|
855
|
+
},
|
|
856
|
+
}))
|
|
857
|
+
return profile.displayName
|
|
858
|
+
}
|
|
859
|
+
} catch (error) {
|
|
860
|
+
logger.warn(`讀取 LINE 成員資料失敗(${userId}):${errorText(error)}`)
|
|
861
|
+
}
|
|
862
|
+
return ''
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function groupImportedText(groupId) {
|
|
866
|
+
const imported = store.getState().groups?.[groupId]?.imported
|
|
867
|
+
if (!imported) return ''
|
|
868
|
+
const parts = ['【匯入的群組歷史】']
|
|
869
|
+
if (imported.summary) parts.push(imported.summary)
|
|
870
|
+
if (Array.isArray(imported.topics) && imported.topics.length > 0) parts.push(`常聊主題:${imported.topics.join('、')}`)
|
|
871
|
+
parts.push('(以上是匯入的舊聊天整理出來的背景;自然接話就好,不要逐條複述,也不要說「根據匯入紀錄」。)')
|
|
872
|
+
return parts.join('\n').slice(0, 1400)
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function groupMemberKey(groupId, name) {
|
|
876
|
+
const state = store.getState()
|
|
877
|
+
const rows = [...Object.entries(state.users || {}), ...Object.entries(state.people || {})]
|
|
878
|
+
const hit = rows.find(([, item]) => item?.displayName === name)
|
|
879
|
+
return hit ? `${groupId}:${hit[0]}` : `${groupId}:name:${name}`
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async function importGroupChat({ groupId, text, dryRun = false }) {
|
|
883
|
+
const cfg = store.get()
|
|
884
|
+
const parsed = chatImport.parseLineExport(text)
|
|
885
|
+
const digest = chatImport.buildDigest(parsed)
|
|
886
|
+
if (digest.messageCount === 0) throw new Error('讀不到聊天記錄,請確認是 LINE 匯出的 .txt')
|
|
887
|
+
if (dryRun) {
|
|
888
|
+
return {
|
|
889
|
+
dryRun: true,
|
|
890
|
+
groupId,
|
|
891
|
+
title: parsed.title,
|
|
892
|
+
messageCount: digest.messageCount,
|
|
893
|
+
participants: parsed.participants.map((item) => item.name),
|
|
894
|
+
digestChars: digest.text.length,
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
if (!store.getState().groups?.[groupId]) ensureGroup(groupId, 'group')
|
|
898
|
+
const sessionId = `session-import-${randomUUID()}`
|
|
899
|
+
const reply = await runner.turn({
|
|
900
|
+
sessionId,
|
|
901
|
+
text: chatImport.buildImportPrompt(digest.text),
|
|
902
|
+
persona: null,
|
|
903
|
+
personaText: '',
|
|
904
|
+
contextText: '',
|
|
905
|
+
tools: [],
|
|
906
|
+
cwd: ensureUserWorkspace(groupId) || cfg.agent.cwd || undefined,
|
|
907
|
+
})
|
|
908
|
+
const result = chatImport.parseImportResult(reply.text) || { summary: '', topics: [], members: [] }
|
|
909
|
+
const botName = runtime.botInfo?.displayName || ''
|
|
910
|
+
const members = []
|
|
911
|
+
let added = 0
|
|
912
|
+
const state = store.getState()
|
|
913
|
+
for (const member of result.members) {
|
|
914
|
+
if (!member.name) continue
|
|
915
|
+
if (botName && member.name === botName) continue
|
|
916
|
+
if (/^大肥魚$/.test(member.name) && botName === '大肥魚') continue
|
|
917
|
+
const key = groupMemberKey(groupId, member.name)
|
|
918
|
+
const existing = memory.list(key).map((entry) => entry.text)
|
|
919
|
+
const notes = []
|
|
920
|
+
for (const note of member.notes) {
|
|
921
|
+
if (!note || existing.includes(note)) continue
|
|
922
|
+
memory.add(key, { text: note, kind: 'fact', importance: 3, source: 'import' })
|
|
923
|
+
notes.push(note)
|
|
924
|
+
added += 1
|
|
925
|
+
}
|
|
926
|
+
members.push({ name: member.name, key, notes })
|
|
927
|
+
}
|
|
928
|
+
const imported = {
|
|
929
|
+
at: Date.now(),
|
|
930
|
+
messageCount: digest.messageCount,
|
|
931
|
+
title: parsed.title,
|
|
932
|
+
participants: parsed.participants.map((item) => item.name),
|
|
933
|
+
summary: result.summary || '',
|
|
934
|
+
topics: result.topics || [],
|
|
935
|
+
members,
|
|
936
|
+
}
|
|
937
|
+
store.updateState((current) => {
|
|
938
|
+
const target = current.groups?.[groupId]
|
|
939
|
+
if (!target) return current
|
|
940
|
+
return { ...current, groups: { ...current.groups, [groupId]: { ...target, imported } } }
|
|
941
|
+
})
|
|
942
|
+
logger.info(`匯入群組聊天:${parsed.title || groupId},${digest.messageCount} 則,寫入 ${added} 筆記憶`)
|
|
943
|
+
return {
|
|
944
|
+
groupId,
|
|
945
|
+
title: parsed.title,
|
|
946
|
+
messageCount: digest.messageCount,
|
|
947
|
+
participants: parsed.participants.map((item) => item.name),
|
|
948
|
+
summary: result.summary || '',
|
|
949
|
+
topics: result.topics || [],
|
|
950
|
+
members: members.map((item) => ({ name: item.name, notes: item.notes.length })),
|
|
951
|
+
memories: added,
|
|
952
|
+
sessionId,
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function groupRecentText(groupId) {
|
|
957
|
+
const rows = (store.getState().groups?.[groupId]?.recent || []).slice(-6)
|
|
958
|
+
if (rows.length === 0) return ''
|
|
959
|
+
return [
|
|
960
|
+
'【群組最近的訊息】',
|
|
961
|
+
...rows.map((row) => `${row.name || row.userId}:${row.text}`),
|
|
962
|
+
'(上面是群組裡最近幾則別人的訊息;現在輪到你回應,可以自然接話,但不要逐條回顧。)',
|
|
963
|
+
].join('\n')
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function pushRecent(groupId, entry) {
|
|
967
|
+
store.updateState((state) => {
|
|
968
|
+
const group = state.groups?.[groupId]
|
|
969
|
+
if (!group) return state
|
|
970
|
+
const recent = [...(Array.isArray(group.recent) ? group.recent : []), entry].slice(-12)
|
|
971
|
+
return { ...state, groups: { ...state.groups, [groupId]: { ...group, recent } } }
|
|
972
|
+
})
|
|
973
|
+
}
|
|
974
|
+
|
|
838
975
|
function isAllowed(userId, cfg) {
|
|
839
976
|
const list = cfg.line.allowedUserIds || []
|
|
840
977
|
return list.length === 0 || list.includes(userId)
|
|
@@ -1278,17 +1415,25 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1278
1415
|
if (!text) return
|
|
1279
1416
|
|
|
1280
1417
|
let groupReason = ''
|
|
1418
|
+
let groupRecent = ''
|
|
1419
|
+
let senderName = ''
|
|
1281
1420
|
if (sourceKind !== 'user') {
|
|
1421
|
+
senderName = await ensurePerson(userId)
|
|
1422
|
+
const bufferText = messageType === 'sticker' ? '(傳了一個 LINE 貼圖)' : clip(text, 300)
|
|
1282
1423
|
const gate = groupGate(event.message, cfg)
|
|
1283
1424
|
if (!gate.reply) {
|
|
1425
|
+
pushRecent(chatId, { userId, name: senderName || userId, text: bufferText, at: Date.now() })
|
|
1284
1426
|
logger.info(`群組訊息(${gate.reason}):這次不回`)
|
|
1285
1427
|
return
|
|
1286
1428
|
}
|
|
1429
|
+
groupRecent = groupRecentText(chatId)
|
|
1430
|
+
pushRecent(chatId, { userId, name: senderName || userId, text: bufferText, at: Date.now() })
|
|
1287
1431
|
groupReason = gate.reason
|
|
1288
1432
|
logger.info(`群組訊息:回覆(${gate.reason})`)
|
|
1289
1433
|
}
|
|
1290
1434
|
|
|
1291
1435
|
const conversationKey = sourceKind === 'user' ? userId : chatId
|
|
1436
|
+
const memoryKey = sourceKind === 'user' ? userId : `${chatId}:${userId}`
|
|
1292
1437
|
const user = sourceKind === 'user' ? ensureUser(userId) : ensureGroup(chatId, sourceKind)
|
|
1293
1438
|
const bucketName = sourceKind === 'user' ? 'users' : 'groups'
|
|
1294
1439
|
store.updateState((state) => {
|
|
@@ -1331,8 +1476,8 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1331
1476
|
persona: applied.persona,
|
|
1332
1477
|
personaText: applied.personaText,
|
|
1333
1478
|
personaClient: persona(),
|
|
1334
|
-
contextText: agentContextText(
|
|
1335
|
-
tools: memoryToolsFor(
|
|
1479
|
+
contextText: agentContextText(memoryKey, sourceKind, [groupImportedText(conversationKey), groupRecent].filter(Boolean).join('\n\n')),
|
|
1480
|
+
tools: memoryToolsFor(memoryKey),
|
|
1336
1481
|
images: messageImages,
|
|
1337
1482
|
cwd: ensureUserWorkspace(conversationKey) || cfg.agent.cwd || undefined,
|
|
1338
1483
|
})
|
|
@@ -1855,21 +2000,39 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1855
2000
|
case 'memory-list': {
|
|
1856
2001
|
const only = String(payload.userId || '').trim()
|
|
1857
2002
|
const state = store.getState()
|
|
1858
|
-
const mapEntry = (kind) => ([id, item]) =>
|
|
2003
|
+
const mapEntry = (kind) => ([id, item]) => {
|
|
2004
|
+
let displayName = item?.displayName || ''
|
|
2005
|
+
let rowKind = kind
|
|
2006
|
+
if (id.includes(':')) {
|
|
2007
|
+
const parts = id.split(':')
|
|
2008
|
+
const groupId = parts.shift()
|
|
2009
|
+
const memberId = parts.join(':')
|
|
2010
|
+
const group = state.groups?.[groupId]
|
|
2011
|
+
const memberName = memberId.startsWith('name:') ? memberId.slice(5) : personName(memberId) || memberId
|
|
2012
|
+
displayName = `${group?.displayName || groupId}/${memberName}`
|
|
2013
|
+
rowKind = 'group-member'
|
|
2014
|
+
}
|
|
2015
|
+
return {
|
|
1859
2016
|
userId: id,
|
|
1860
2017
|
id,
|
|
1861
|
-
kind,
|
|
1862
|
-
displayName
|
|
2018
|
+
kind: rowKind,
|
|
2019
|
+
displayName,
|
|
1863
2020
|
entries: memory.list(id).map((entry) => ({
|
|
1864
2021
|
...entry,
|
|
1865
2022
|
createdText: new Date(entry.createdAt).toISOString(),
|
|
1866
2023
|
expiresText: entry.expiresAt ? new Date(entry.expiresAt).toISOString() : '',
|
|
1867
2024
|
})),
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
const rows = memory.keys()
|
|
2028
|
+
.filter((id) => !only || id === only || id.startsWith(`${only}:`))
|
|
2029
|
+
.map((id) => {
|
|
2030
|
+
if (id.includes(':')) return mapEntry('group-member')([id, null])
|
|
2031
|
+
if (state.users?.[id]) return mapEntry('user')([id, state.users[id]])
|
|
2032
|
+
if (state.groups?.[id]) return mapEntry('group')([id, state.groups[id]])
|
|
2033
|
+
return mapEntry('unknown')([id, null])
|
|
2034
|
+
})
|
|
2035
|
+
.filter((row) => row.entries.length > 0)
|
|
1873
2036
|
return { users: rows, total: rows.reduce((sum, row) => sum + row.entries.length, 0) }
|
|
1874
2037
|
}
|
|
1875
2038
|
case 'memory-add': {
|
|
@@ -1955,6 +2118,14 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1955
2118
|
}
|
|
1956
2119
|
return { userId, personaId, personaName: picked.name || '', personaSource: picked.source || 'dafeiyu' }
|
|
1957
2120
|
}
|
|
2121
|
+
case 'group-import': {
|
|
2122
|
+
const groupId = String(payload.groupId || '').trim()
|
|
2123
|
+
const text = String(payload.text || '')
|
|
2124
|
+
if (!groupId) throw new Error('需要 groupId')
|
|
2125
|
+
if (!text) throw new Error('需要聊天記錄內容')
|
|
2126
|
+
return importGroupChat({ groupId, text, dryRun: payload.dryRun === true })
|
|
2127
|
+
}
|
|
2128
|
+
|
|
1958
2129
|
case 'group-persona': {
|
|
1959
2130
|
const groupId = String(payload.groupId || '').trim()
|
|
1960
2131
|
if (!groupId) throw new Error('缺少 groupId')
|
|
@@ -1989,12 +2160,13 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1989
2160
|
logger.warn(`清空工作資料夾失敗:${errorText(error)}`)
|
|
1990
2161
|
}
|
|
1991
2162
|
}
|
|
2163
|
+
const cleared = memory.forgetPrefix(`${groupId}:`)
|
|
1992
2164
|
store.updateState((state) => {
|
|
1993
2165
|
const next = { ...state, groups: { ...state.groups } }
|
|
1994
2166
|
delete next.groups[groupId]
|
|
1995
2167
|
return next
|
|
1996
2168
|
})
|
|
1997
|
-
return { groupId, reset: true }
|
|
2169
|
+
return { groupId, reset: true, memory: cleared }
|
|
1998
2170
|
}
|
|
1999
2171
|
case 'user-reset': {
|
|
2000
2172
|
const userId = String(payload.userId || '')
|
package/lib/memory.mjs
CHANGED
|
@@ -205,5 +205,22 @@ export function createMemoryStore({ dir, logger, getConfig }) {
|
|
|
205
205
|
]
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
-
|
|
208
|
+
function keys() {
|
|
209
|
+
return Object.keys(store.users).filter((key) => (store.users[key]?.entries?.length || 0) > 0)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function forgetPrefix(prefix) {
|
|
213
|
+
const key = String(prefix || '')
|
|
214
|
+
if (!key) return { removed: 0, keys: [] }
|
|
215
|
+
const keys = Object.keys(store.users).filter((item) => item.startsWith(key))
|
|
216
|
+
let removed = 0
|
|
217
|
+
for (const item of keys) {
|
|
218
|
+
removed += store.users[item]?.entries?.length || 0
|
|
219
|
+
delete store.users[item]
|
|
220
|
+
}
|
|
221
|
+
if (keys.length > 0) save()
|
|
222
|
+
return { removed, keys }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { file, list, add, forget, prune, stats, formatForContext, toolsFor, forgetPrefix, keys }
|
|
209
226
|
}
|