dsh-line-bot 0.7.0 → 0.7.2
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 +10 -0
- package/README.en.md +1 -0
- package/README.ja.md +1 -0
- package/README.ko.md +1 -0
- package/README.md +1 -0
- package/README.zh-CN.md +1 -0
- package/client.js +43 -11
- package/lib/chat-import.mjs +172 -0
- package/lib/host.mjs +115 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.2
|
|
4
|
+
|
|
5
|
+
- 匯入聊天時若成員還沒對應到 LINE 帳號,先用「名字」記住;本人第一次在群組發言時,這些記憶也會一起帶入。
|
|
6
|
+
|
|
7
|
+
## 0.7.1
|
|
8
|
+
|
|
9
|
+
- 群組卡片新增「匯入聊天」:選 LINE 匯出的 `.txt`,自動整理群組摘要、常聊主題與各成員的記憶點;比較晚加入群組也能補上背景。
|
|
10
|
+
- 匯入的成員記憶存成 `群組ID:name:名字`(已知帳號則用 LINE userId),會出現在面板「個人記憶」;群組回覆會帶入匯入摘要。
|
|
11
|
+
- 匯入結果只存在本機 `state.json` 與 `memories.json`。
|
|
12
|
+
|
|
3
13
|
## 0.7.0
|
|
4
14
|
|
|
5
15
|
- 群組內每位成員有自己的記憶點(同一個人到不同群組也分開),回覆時會注入該成員的記憶。
|
package/README.en.md
CHANGED
|
@@ -143,6 +143,7 @@ When the bot is added to a group (or a multi-user room):
|
|
|
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
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).
|
|
146
147
|
- Proactive messages and DeepSeek balance reminders still target direct chats only.
|
|
147
148
|
|
|
148
149
|
## Per-user memory and personas
|
package/README.ja.md
CHANGED
|
@@ -143,6 +143,7 @@ Bot がグループ(または複数人のルーム)に追加されたとき
|
|
|
143
143
|
- グループごとに **DSH セッション・ワークスペース・ペルソナ・個人メモリ** を持ち、1 対 1 のチャットや他のグループとは分かれています。
|
|
144
144
|
- グループでも `人設` コマンドが使えます。パネルの「グループ」カードで返信確率・スイッチ・グループごとのペルソナ・メモリの消去を設定できます。
|
|
145
145
|
- グループ内でも**メンバーごとに別々の記憶**を持ちます。返信時は直近 4~6 件の他人のメッセージも参考にして話を拾います。
|
|
146
|
+
- グループに後から参加した場合は、パネルの「グループ」カードで **チャットを読み込む** を押し、LINE が書き出した `.txt` を選ぶと、グループ要約とメンバーごとの記憶ポイントを作ります(保存はローカルのみ)。
|
|
146
147
|
- 自発メッセージと DeepSeek 残高の通知は 1 対 1 の相手にだけ送られます。
|
|
147
148
|
|
|
148
149
|
## ユーザーごとのメモリとペルソナ
|
package/README.ko.md
CHANGED
|
@@ -143,6 +143,7 @@ Bot이 그룹(또는 다인 룸)에 추가되면:
|
|
|
143
143
|
- 그룹마다 **DSH 세션, 작업 폴더, 페르소나, 개인 기억**이 따로 있으며 1:1 채팅이나 다른 그룹과 분리됩니다.
|
|
144
144
|
- 그룹에서도 `人設` 명령을 쓸 수 있습니다. 패널의 "그룹" 카드에서 답장 확률, 스위치, 그룹별 페르소나, 기억 지우기를 설정할 수 있습니다.
|
|
145
145
|
- 그룹 안에서도 **멤버마다 별도의 기억**을 가집니다. 답장할 때 최근 4~6개의 다른 사람 메시지도 참고해 대화를 이어갑니다.
|
|
146
|
+
- 그룹에 늦게 참여했다면 패널의 “그룹” 카드에서 **채팅 가져오기**를 눌러 LINE에서 내보낸 `.txt`를 선택하세요. 그룹 요약과 멤버별 기억 포인트를 만들어 줍니다(로컬에만 저장).
|
|
146
147
|
- 자발적 메시지와 DeepSeek 잔액 알림은 여전히 1:1 대상에게만 갑니다.
|
|
147
148
|
|
|
148
149
|
## 사용자별 기억과 페르소나
|
package/README.md
CHANGED
|
@@ -143,6 +143,7 @@ Bot 被加進群組(或多人聊天室)後:
|
|
|
143
143
|
- 每個群組有自己的 **DSH session、工作資料夾、人設與個人記憶**,和一對一聊天、其他群組都分開。
|
|
144
144
|
- 群組裡也能用 `人設` 指令;面板「群組」卡可以調回覆機率、開關、個別群組的人設與清空記憶。
|
|
145
145
|
- 每個成員在群組裡有**自己的記憶點**;回覆時也會參考最近 4~6 則別人的訊息,接得上話題。
|
|
146
|
+
- 比較晚加入群組時,可以在面板「群組」卡片按「匯入聊天」,選 LINE 匯出的 `.txt`;會整理出群組摘要與各成員的記憶點(只存本機)。
|
|
146
147
|
- 主動傳訊與 DeepSeek 餘額提醒仍然只找一對一的對象。
|
|
147
148
|
|
|
148
149
|
## 每位使用者各自的記憶與人設
|
package/README.zh-CN.md
CHANGED
|
@@ -143,6 +143,7 @@ Bot 被加入群组(或多人聊天室)后:
|
|
|
143
143
|
- 每个群组有自己的 **DSH session、工作文件夹、人设与个人记忆**,和一对一聊天、其他群组分开。
|
|
144
144
|
- 群组里也能用 `人设` 指令;面板「群组」卡可调回复概率、开关、个别群组的人设与清空记忆。
|
|
145
145
|
- 每个成员在群组里有**自己的记忆点**;回复时也会参考最近 4~6 条别人的消息,接得上话题。
|
|
146
|
+
- 比较晚加入群组时,可以在面板“群组”卡片按“汇入聊天”,选择 LINE 导出的 `.txt`;会整理出群组摘要与各成员的记忆点(只存本机)。
|
|
146
147
|
- 主动传讯与 DeepSeek 余额提醒仍然只找一对一的对象。
|
|
147
148
|
|
|
148
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/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.7.
|
|
45
|
+
export const VERSION = '0.7.2'
|
|
45
46
|
|
|
46
47
|
// 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
|
|
47
48
|
// 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
|
|
@@ -861,6 +862,97 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
861
862
|
return ''
|
|
862
863
|
}
|
|
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
|
+
|
|
864
956
|
function groupRecentText(groupId) {
|
|
865
957
|
const rows = (store.getState().groups?.[groupId]?.recent || []).slice(-6)
|
|
866
958
|
if (rows.length === 0) return ''
|
|
@@ -1384,7 +1476,15 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1384
1476
|
persona: applied.persona,
|
|
1385
1477
|
personaText: applied.personaText,
|
|
1386
1478
|
personaClient: persona(),
|
|
1387
|
-
contextText: agentContextText(
|
|
1479
|
+
contextText: agentContextText(
|
|
1480
|
+
memoryKey,
|
|
1481
|
+
sourceKind,
|
|
1482
|
+
[
|
|
1483
|
+
groupImportedText(conversationKey),
|
|
1484
|
+
groupRecent,
|
|
1485
|
+
senderName && `${chatId}:name:${senderName}` !== memoryKey ? memoryContextText(`${chatId}:name:${senderName}`) : '',
|
|
1486
|
+
].filter(Boolean).join('\n\n'),
|
|
1487
|
+
),
|
|
1388
1488
|
tools: memoryToolsFor(memoryKey),
|
|
1389
1489
|
images: messageImages,
|
|
1390
1490
|
cwd: ensureUserWorkspace(conversationKey) || cfg.agent.cwd || undefined,
|
|
@@ -1912,9 +2012,12 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1912
2012
|
let displayName = item?.displayName || ''
|
|
1913
2013
|
let rowKind = kind
|
|
1914
2014
|
if (id.includes(':')) {
|
|
1915
|
-
const
|
|
2015
|
+
const parts = id.split(':')
|
|
2016
|
+
const groupId = parts.shift()
|
|
2017
|
+
const memberId = parts.join(':')
|
|
1916
2018
|
const group = state.groups?.[groupId]
|
|
1917
|
-
|
|
2019
|
+
const memberName = memberId.startsWith('name:') ? memberId.slice(5) : personName(memberId) || memberId
|
|
2020
|
+
displayName = `${group?.displayName || groupId}/${memberName}`
|
|
1918
2021
|
rowKind = 'group-member'
|
|
1919
2022
|
}
|
|
1920
2023
|
return {
|
|
@@ -2023,6 +2126,14 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
2023
2126
|
}
|
|
2024
2127
|
return { userId, personaId, personaName: picked.name || '', personaSource: picked.source || 'dafeiyu' }
|
|
2025
2128
|
}
|
|
2129
|
+
case 'group-import': {
|
|
2130
|
+
const groupId = String(payload.groupId || '').trim()
|
|
2131
|
+
const text = String(payload.text || '')
|
|
2132
|
+
if (!groupId) throw new Error('需要 groupId')
|
|
2133
|
+
if (!text) throw new Error('需要聊天記錄內容')
|
|
2134
|
+
return importGroupChat({ groupId, text, dryRun: payload.dryRun === true })
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2026
2137
|
case 'group-persona': {
|
|
2027
2138
|
const groupId = String(payload.groupId || '').trim()
|
|
2028
2139
|
if (!groupId) throw new Error('缺少 groupId')
|