dsh-line-bot 0.8.3 → 0.9.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 +14 -0
- package/README.en.md +15 -2
- package/README.ja.md +15 -2
- package/README.ko.md +15 -2
- package/README.md +15 -2
- package/README.zh-CN.md +15 -2
- package/client.js +83 -10
- package/lib/agent-runner.mjs +14 -3
- package/lib/config.mjs +19 -3
- package/lib/host.mjs +110 -11
- package/lib/jokes.mjs +211 -0
- package/lib/tieba-sync.mjs +160 -37
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.1
|
|
4
|
+
- 貼吧學習同步的目標下拉現在也包含「插件自訂人設」(`local:`);選自訂人設時會寫入 `<DSH_HOME>/line-bot/persona-overlays/<id>.md` 覆蓋層,每輪注入。
|
|
5
|
+
- 讀不到大肥魚 HTTP bridge 時,改成直接讀取 `<DSH_HOME>/storages/dsh-persona-dafeiyu/personas.json` 並列出內建人設,不再讓同步目標變空。
|
|
6
|
+
- 修正本機自訂人設同步完成後回報的模式(應為 overlay)。
|
|
7
|
+
- 貼吧梗只從梗類貼吧匯入(一般回覆碎片不再被當成梗);內建種子擴充到 24 則。
|
|
8
|
+
- 明確要求角色在主人說「不要圖」時不要加表情包標記。
|
|
9
|
+
|
|
10
|
+
## 0.9.0
|
|
11
|
+
- 玩梗:新增文字梗庫(內建種子、貼吧高讚金句、wanjiqi-meme 彈幕梗),面板可個別開關來源、調整每輪注入數量、新增/刪除梗;每輪只餵幾條給角色,並限制一次回覆最多用一個。
|
|
12
|
+
- 貼吧學習可寫入內建人設:大肥魚、阿茶、听澜等內建人設不能改原文,改成寫入 `<DSH_HOME>/line-bot/persona-overlays/<id>.md` 覆蓋層,每輪注入、不用重載;自訂人設仍直接改本文。
|
|
13
|
+
- 面板「貼吧學習同步」的目標人設現在包含內建人設;預設同步到 `dafeiyu`。
|
|
14
|
+
- 新增「加入梗類貼吧」(弱智吧、抽象吧、搞笑吧、梗吧)與「匯入貼吧金句」動作;梗庫存在 `<DSH_HOME>/line-bot/jokes.json`。
|
|
15
|
+
- 貼吧同步目標未設定時改用 `dafeiyu`,不再顯示「尚未選擇要同步的人設」;貼吧金句只從梗類貼吧匯入,避免一般回覆碎片被當成梗。
|
|
16
|
+
|
|
3
17
|
## 0.8.3
|
|
4
18
|
- 表情包提示詞更明確:貼圖只能從已安裝的 dsh-meme 圖庫挑選,不能自行產生、上傳、貼外部網址或捏造路徑;清單沒有合適的圖就不要硬挑,並告知使用者可到 dsh-meme 加入新圖。
|
|
5
19
|
- 模型被問到「貼圖哪裡來的」時,會照實回答 dsh-meme。
|
package/README.en.md
CHANGED
|
@@ -94,7 +94,8 @@ At the top of the panel is a **sticky save bar**: "Save changes / Refresh" stays
|
|
|
94
94
|
| Cloudflare Tunnel | quick / API token modes, token, hostname, tunnel name, Account/Zone ID, download cloudflared, start/stop, auto-configure webhook, bring your own public URL |
|
|
95
95
|
| Model & provider | Pick the provider/model and reasoning effort for LINE replies; disables DeepSeek balance when the provider is not deepseek-account |
|
|
96
96
|
| Reply persona | Choose a persona (dafeiyu or a plugin custom one), add/edit/delete custom personas, copy the current persona into a custom one, enforce, allow users to switch personas, switch dafeiyu to session mode, test an Agent reply |
|
|
97
|
-
| Tieba learning sync | Writes
|
|
97
|
+
| Tieba learning sync | Writes Tieba tone samples into the persona (built-ins use an overlay), preview diff, sync now |
|
|
98
|
+
| Jokes | Text joke library: built-in seeds, Tieba quotes, wanjiqi barrage memes; per-source toggles, per-turn injection count, add/delete entries |
|
|
98
99
|
| Personal memory | Per-user long-term memory, expiry cleanup, daily self-review, manual delete |
|
|
99
100
|
| Memes | Reply with images/stickers from the dsh-meme library, enable/disable, image limit, and sample previews |
|
|
100
101
|
| Sticker vision | Fetch incoming LINE stickers and show them to the model (can be disabled) |
|
|
@@ -160,12 +161,24 @@ When the bot is added to a group (or a multi-user room):
|
|
|
160
161
|
|
|
161
162
|
The "dive into Tieba" plugin evolves its own tone at most once a day (stored in `<DSH_HOME>/tieba-duty/persona.json`), and that is a separate system from the dafeiyu persona LINE uses (`<DSH_HOME>/storages/dsh-persona-dafeiyu/personas.json`). This plugin can bridge them:
|
|
162
163
|
|
|
163
|
-
- In the panel, "Reply persona → Tieba learning sync" lets you set the toggle, the target persona (dafeiyu
|
|
164
|
+
- In the panel, "Reply persona → Tieba learning sync" lets you set the toggle, the target persona (any dafeiyu persona, including built-ins), the check interval (default 60 minutes), whether to sync personality numbers, and whether to reload the dafeiyu plugin after writing. Built-in personas cannot be edited in place, so their learning goes to `<DSH_HOME>/line-bot/persona-overlays/<id>.md` and is injected every turn without reloading; custom personas are edited directly.
|
|
164
165
|
- Each check compares the Tieba persona version; it only acts on a new version. It replaces only the `## 貼吧學來的口氣` section of the target persona text (appending it if missing) and never overwrites other hand-written content; the personality numbers (gentle / sharp / abstract / talkative / image bias) can be updated too.
|
|
165
166
|
- Before writing it backs up to `<DSH_HOME>/line-bot/persona-backups/personas-<time>.json`.
|
|
166
167
|
- After writing it restarts `dsh-persona-dafeiyu` through the Cordis loader so it re-reads the store; a failed restart is only a warning — the text is already written and takes effect on the next plugin reload.
|
|
167
168
|
- If the Tieba plugin is not running or the Tieba persona does not exist, the check is skipped (`pending` shows why it has not synced yet).
|
|
168
169
|
|
|
170
|
+
## Jokes and memes (text)
|
|
171
|
+
|
|
172
|
+
The **Jokes** panel lets the persona use a little wordplay. Three sources can be toggled independently:
|
|
173
|
+
|
|
174
|
+
- **Built-in seeds**: 10 common internet jokes with usage notes.
|
|
175
|
+
- **Tieba quotes**: import highly-liked short replies from the Tieba style library.
|
|
176
|
+
- **wanjiqi memes**: 6657 live-room barrage memes (optional; heavier style, off by default).
|
|
177
|
+
|
|
178
|
+
Each turn injects a few entries; at most one per reply. The prompt tells the model not to explain the joke, not to force it, and not to use jokes in serious situations. Add your own by filling in the text and usage fields, then **Add to joke library**; the file lives at `<DSH_HOME>/line-bot/jokes.json`.
|
|
179
|
+
|
|
180
|
+
**Add meme bars to Tieba** appends 弱智吧, 抽象吧, 搞笑吧 and 梗吧 to the Tieba plugin's bar list (with a backup). You still need to start a Tieba round for it to learn.
|
|
181
|
+
|
|
169
182
|
## Tieba activity awareness
|
|
170
183
|
|
|
171
184
|
With **Advanced → Let the agent know about Tieba activity** enabled (default), each reply is preceded by a small block of background facts injected into the system prompt, built from the Tieba plugin's records:
|
package/README.ja.md
CHANGED
|
@@ -94,7 +94,8 @@ npx dsh-line-bot install --no-companions
|
|
|
94
94
|
| Cloudflare Tunnel | quick/API トークンモード、トークン、hostname、トンネル名、Account/Zone ID、cloudflared のダウンロード、起動/停止、webhook 自動設定、独自の公開 URL |
|
|
95
95
|
| モデルとプロバイダー | LINE 返信で使う provider/モデルと推論強度を選択。deepseek-account 以外では DeepSeek 残高を自動で無効化 |
|
|
96
96
|
| 返信ペルソナ | ペルソナ選択(大肥魚またはプラグインのカスタム)、カスタムペルソナの追加・編集・削除、現在のペルソナをカスタムに複製、強制適用、ユーザーによる切り替え許可、大肥魚を session モードに切り替え、Agent 返信のテスト |
|
|
97
|
-
| 貼吧学習の同期 |
|
|
97
|
+
| 貼吧学習の同期 | 貼吧で学んだ口調をペルソナに書き戻す(内蔵ペルソナはオーバーレイ)、差分プレビュー、今すぐ同期 |
|
|
98
|
+
| ジョーク | テキストネタ集:内蔵シード、貼吧名台詞、wanjiqi 弾幕ネタ。ソース別の切り替え、ターンごとの注入数、追加/削除 |
|
|
98
99
|
| 個人メモリ | ユーザーごとの長期記憶、期限切れの自動削除、毎日の自己整理、手動削除 |
|
|
99
100
|
| 表情パック | dsh-meme の画像ライブラリで画像/スタンプを返信、オンオフ、1 回の上限、サンプル表示 |
|
|
100
101
|
| スタンプ理解 | LINE スタンプを受信したら画像を取得してモデルに見せる(オフ可) |
|
|
@@ -160,12 +161,24 @@ Bot がグループ(または複数人のルーム)に追加されたとき
|
|
|
160
161
|
|
|
161
162
|
「刷貼吧」プラグインは 1 日最大 1 回、自分の口調を進化させます(`<DSH_HOME>/tieba-duty/persona.json`)。これは LINE が使う大肥魚ペルソナ(`<DSH_HOME>/storages/dsh-persona-dafeiyu/personas.json`)とは別系統で、もともと連動していません。このプラグインは両者をつなげます。
|
|
162
163
|
|
|
163
|
-
- パネルの「返信ペルソナ →
|
|
164
|
+
- パネルの「返信ペルソナ → 貼吧学習の同期」で、オン/オフ、同期先ペルソナ(大肥魚のペルソナ、内蔵も含む)、確認間隔(既定 60 分)、性格値も同期するか、書き込み後に大肥魚プラグインを再読み込みするかを設定できます。内蔵ペルソナは本文を編集できないため `<DSH_HOME>/line-bot/persona-overlays/<id>.md` に書き、毎ターン注入します(再読み込み不要)。カスタムペルソナは本文を直接編集します。
|
|
164
165
|
- 確認のたびに貼吧ペルソナのバージョンを比べ、新しい版のときだけ動作します。書き込み時は対象ペルソナ本文の `## 貼吧學來的口氣` セクションだけを置き換え(なければ追記)、他の手書き内容は上書きしません。性格値(温柔/毒舌/抽象/話癆/発図傾向)も同時に更新できます。
|
|
165
166
|
- 書き込み前に `<DSH_HOME>/line-bot/persona-backups/personas-<時刻>.json` へバックアップします。
|
|
166
167
|
- 書き込み後は Cordis loader 経由で `dsh-persona-dafeiyu` を再起動し、store を読み直させます。再起動に失敗しても警告だけで、本文は既に書き込まれており、次回のプラグイン再読み込みで反映されます。
|
|
167
168
|
- 貼吧プラグインが動いていない、または貼吧ペルソナが存在しない場合はスキップします(`pending` に未同期の理由が出ます)。
|
|
168
169
|
|
|
170
|
+
## ジョーク(言葉のネタ)
|
|
171
|
+
|
|
172
|
+
**ジョーク**パネルでは、ペルソナが自然に言葉のネタを使えるようになります。3 つのソースを個別に切り替えられます。
|
|
173
|
+
|
|
174
|
+
- **内蔵シード**:よくあるネットのネタ 10 個(使いどころ付き)。
|
|
175
|
+
- **貼吧の名台詞**:貼吧スタイルライブラリの高評価ショートコメントをインポート。
|
|
176
|
+
- **wanjiqi ネタ**:6657 配信室の弾幕ネタ(任意;クセが強いので既定はオフ)。
|
|
177
|
+
|
|
178
|
+
毎ターン数個だけプロンプトに入り、返信では最大 1 個。ネタの説明をしない、無理に使わない、真面目な話や落ち込んでいるときは使わない、というルール付きです。カード下の入力欄から自分で追加でき、保存先は `<DSH_HOME>/line-bot/jokes.json` です。
|
|
179
|
+
|
|
180
|
+
**「梗類貼吧を追加」**は弱智吧・抽象吧・搞笑吧・梗吧を貼吧プラグインの bars に追加します(設定はバックアップされます)。実際に学習させるには貼吧側で実行開始が必要です。
|
|
181
|
+
|
|
169
182
|
## 貼吧の活動状況
|
|
170
183
|
|
|
171
184
|
パネルの「詳細設定 → Agent に貼吧の活動を知らせる」(既定でオン)を有効にすると、返信のたびに「刷貼吧」プラグインの記録から作った短い背景情報を system prompt に注入します:
|
package/README.ko.md
CHANGED
|
@@ -94,7 +94,8 @@ npx dsh-line-bot install --no-companions
|
|
|
94
94
|
| Cloudflare Tunnel | quick/API 토큰 모드, 토큰, hostname, 터널 이름, Account/Zone ID, cloudflared 다운로드, 시작/중지, webhook 자동 설정, 직접 제공하는 공개 URL |
|
|
95
95
|
| 모델과 공급자 | LINE 답장에 쓸 provider/모델과 추론 강도 선택. deepseek-account가 아니면 DeepSeek 잔액 자동 비활성화 |
|
|
96
96
|
| 답장 페르소나 | 페르소나 선택(大肥魚 또는 플러그인 커스텀), 커스텀 페르소나 추가/편집/삭제, 현재 페르소나를 커스텀으로 복사, 강제 적용, 사용자 직접 전환 허용, 大肥魚를 session 모드로 전환, Agent 답장 테스트 |
|
|
97
|
-
| 貼吧 학습 동기화 | 貼吧에서 배운 말투를
|
|
97
|
+
| 貼吧 학습 동기화 | 貼吧에서 배운 말투를 페르소나에 기록(내장 페르소나는 오버레이), 차이 미리보기, 지금 동기화 |
|
|
98
|
+
| 말장난 | 텍스트 밈: 내장 시드, 貼吧 명언, wanjiqi 탄막 밈. 출처별 켜기/끄기, 턴당 주입 수, 추가/삭제 |
|
|
98
99
|
| 개인 기억 | 사용자별 장기 기억, 만료 자동 삭제, 매일 자기 정리, 수동 삭제 |
|
|
99
100
|
| 이모티콘 | dsh-meme 라이브러리로 이미지/스티커 답장, 켜기/끄기, 1회 최대 장수, 샘플 미리보기 |
|
|
100
101
|
| 스티커 이해 | LINE 스티커를 받으면 이미지를 가져와 모델에 보여 줌 (끌 수 있음) |
|
|
@@ -160,12 +161,24 @@ Bot이 그룹(또는 다인 룸)에 추가되면:
|
|
|
160
161
|
|
|
161
162
|
"刷貼吧" 플러그인은 하루에 최대 한 번 자신의 말투를 진화시킵니다(`<DSH_HOME>/tieba-duty/persona.json`). 이것은 LINE이 쓰는 大肥魚 페르소나(`<DSH_HOME>/storages/dsh-persona-dafeiyu/personas.json`)와 별개 계통이라 원래 서로 연결되지 않습니다. 이 플러그인이 둘을 이어 줍니다.
|
|
162
163
|
|
|
163
|
-
- 패널의 "답장 페르소나 → 貼吧 학습 동기화"에서 켜기/끄기, 동기화 대상 페르소나(大肥魚
|
|
164
|
+
- 패널의 "답장 페르소나 → 貼吧 학습 동기화"에서 켜기/끄기, 동기화 대상 페르소나(大肥魚 페르소나, 내장 포함), 확인 간격(기본 60분), 성격 수치 동기화 여부, 기록 후 大肥魚 플러그인 리로드 여부를 설정할 수 있습니다. 내장 페르소나는 본문을 직접 수정할 수 없어 `<DSH_HOME>/line-bot/persona-overlays/<id>.md`에 기록하고 매 턴 주입하며 재로딩이 필요 없습니다. 사용자 정의 페르소나는 본문을 직접 수정합니다.
|
|
164
165
|
- 확인할 때마다 貼吧 페르소나 버전을 비교해 새 버전일 때만 동작합니다. 기록할 때는 대상 페르소나 본문의 `## 貼吧學來的口氣` 섹션만 바꾸고(없으면 덧붙임) 다른 직접 쓴 내용은 덮어쓰지 않습니다. 성격 수치(温柔/毒舌/抽象/話癆/發図傾向)도 함께 갱신할 수 있습니다.
|
|
165
166
|
- 기록 전 `<DSH_HOME>/line-bot/persona-backups/personas-<시간>.json`으로 백업합니다.
|
|
166
167
|
- 기록 후 기본적으로 Cordis loader를 통해 `dsh-persona-dafeiyu`를 재시작해 store를 다시 읽게 합니다. 재시작 실패는 경고만 남기고, 본문은 이미 기록되어 다음 플러그인 리로드 때 반영됩니다.
|
|
167
168
|
- 貼吧 플러그인이 실행 중이 아니거나 貼吧 페르소나가 없으면 확인을 건너뜁니다(`pending`에 미동기화 이유 표시).
|
|
168
169
|
|
|
170
|
+
## 말장난(텍스트 밈)
|
|
171
|
+
|
|
172
|
+
**말장난** 패널에서 페르소나가 자연스럽게 텍스트 밈을 쓸 수 있습니다. 세 가지 출처를 각각 켜고 끌 수 있습니다.
|
|
173
|
+
|
|
174
|
+
- **내장 시드**: 흔한 인터넷 밈 10개와 사용 시점.
|
|
175
|
+
- **貼吧 명언**: 貼吧 스타일 라이브러리의 고득점 짧은 댓글을 가져옵니다.
|
|
176
|
+
- **wanjiqi 밈**: 6657 라이브 방송 탄막 밈(선택 사항, 스타일이 강해 기본 꺼짐).
|
|
177
|
+
|
|
178
|
+
매 턴 몇 개만 프롬프트에 들어가고, 답장에서는 최대 1개만 사용합니다. 밈을 설명하지 말고, 억지로 넣지 말고, 진지하거나 슬플 때는 쓰지 말라는 규칙이 함께 들어갑니다. 카드 아래에서 직접 추가할 수 있으며 파일은 `<DSH_HOME>/line-bot/jokes.json`에 저장됩니다.
|
|
179
|
+
|
|
180
|
+
**梗類 貼吧 추가**는 弱智吧, 抽象吧, 搞笑吧, 梗吧를 貼吧 플러그인의 bars에 추가합니다(설정은 백업됨). 실제 학습은 貼吧 쪽에서 실행을 시작해야 합니다.
|
|
181
|
+
|
|
169
182
|
## 貼吧 활동 인지
|
|
170
183
|
|
|
171
184
|
패널의 "고급 설정 → Agent가 貼吧 활동을 알도록 함"(기본 켜짐)을 켜면, 답장 전마다 "刷貼吧" 플러그인의 기록에서 만든 짧은 배경 정보를 system prompt에 주입합니다:
|
package/README.md
CHANGED
|
@@ -94,7 +94,8 @@ npx dsh-line-bot install --no-companions
|
|
|
94
94
|
| Cloudflare Tunnel | quick/API Token 模式、Token、hostname、隧道名稱、Account/Zone ID、下載 cloudflared、啟停、自動設定 webhook、自行提供的公開網址 |
|
|
95
95
|
| 模型與接口來源 | 選擇 LINE 回覆的 provider/模型與推理強度;非 deepseek-account 時自動停用 DeepSeek 餘額 |
|
|
96
96
|
| 回覆人設 | 選擇人設(大肥魚或插件自訂)、新增/編輯/刪除自訂人設、把目前人設複製成自訂、強制套用、允許使用者自己換人設、切換大肥魚 session 模式、測試 Agent 回覆 |
|
|
97
|
-
| 貼吧學習同步 |
|
|
97
|
+
| 貼吧學習同步 | 把刷貼吧學到的語氣寫回人設(內建人設用覆蓋層)、預覽差異、立即同步 |
|
|
98
|
+
| 玩梗 | 文字梗庫:內建種子、貼吧金句、wanjiqi 彈幕梗;三個來源可個別開關、每輪注入幾則、新增/刪除梗 |
|
|
98
99
|
| 個人記憶 | 每位使用者分開的長期記憶、到期自動刪、每日請角色整理、手動刪除 |
|
|
99
100
|
| 表情包 | 使用 dsh-meme 圖庫回圖片/貼圖、開關、一次最多幾張、圖庫樣本 |
|
|
100
101
|
| 貼圖理解 | 收到 LINE 貼圖時抓圖給模型看(可關閉) |
|
|
@@ -160,12 +161,24 @@ Bot 被加進群組(或多人聊天室)後:
|
|
|
160
161
|
|
|
161
162
|
「刷貼吧」外掛每天最多演化一次自己的語氣(存在 `<DSH_HOME>/tieba-duty/persona.json`),這和 LINE 用的大肥魚人設(`<DSH_HOME>/storages/dsh-persona-dafeiyu/personas.json`)原本是兩套、互不相通。這個外掛可以把它接起來:
|
|
162
163
|
|
|
163
|
-
- 面板「回覆人設 →
|
|
164
|
+
- 面板「回覆人設 → 貼吧學習同步」可設定開關、同步目標人設(可選大肥魚的人設,含內建)、檢查間隔(預設 60 分鐘)、是否同步性格數值、寫入後是否重載大肥魚外掛。內建人設(大肥魚/阿茶/听澜)不能改原文,會寫進 `<DSH_HOME>/line-bot/persona-overlays/<id>.md` 覆蓋層,每輪注入、不用重載;自訂人設才直接改本文。這項功能需要安裝大肥魚外掛。
|
|
164
165
|
- 每次檢查會比對貼吧人設版本;有新版本才動作。寫入時只替換目標人設正文中的 `## 貼吧學來的口氣` 段落(沒有就附加),不會覆蓋其他手寫內容;性格數值(溫柔/毒舌/抽象/話癆/發圖傾向)可一併更新。
|
|
165
166
|
- 寫入前會備份到 `<DSH_HOME>/line-bot/persona-backups/personas-<時間>.json`。
|
|
166
167
|
- 寫入後預設會透過 Cordis loader 重啟 `dsh-persona-dafeiyu`,讓它重新讀取 store;重啟失敗只會記警告,人設文字仍已寫入,下次外掛重載時生效。
|
|
167
168
|
- 貼吧外掛若沒在跑、或貼吧人設不存在,檢查會跳過(`pending` 顯示尚未同步的原因)。
|
|
168
169
|
|
|
170
|
+
## 玩梗(文字梗)
|
|
171
|
+
|
|
172
|
+
面板「玩梗」讓角色自然用一點文字梗,三個來源可個別開關:
|
|
173
|
+
|
|
174
|
+
- **內建種子**:10 則常見網路梗,附使用時機。
|
|
175
|
+
- **貼吧金句**:從「刷貼吧」的風格庫匯入高讚短句(按「匯入貼吧金句」)。
|
|
176
|
+
- **wanjiqi 梗庫**:6657 直播間彈幕梗(按「匯入 wanjiqi 梗庫」;預設不使用,風格較重)。
|
|
177
|
+
|
|
178
|
+
每輪只會帶入幾則給角色參考,一次回覆最多用一個;提示詞要求不要解釋梗、不要硬塞、正經或難過時不要用。想自己加梗,在卡片下方填「梗」與「用法」後按「新增到梗庫」;梗庫存在 `<DSH_HOME>/line-bot/jokes.json`。
|
|
179
|
+
|
|
180
|
+
「加入梗類貼吧」會把弱智吧、抽象吧、搞笑吧、梗吧加進刷貼吧的 bars(會先備份設定檔);要讓它真的去刷,仍需在刷貼吧那邊啟動。
|
|
181
|
+
|
|
169
182
|
## 貼吧動態(讓角色知道自己剛刷過)
|
|
170
183
|
|
|
171
184
|
面板「進階設定 → 讓 Agent 知道貼吧動態」(預設開)會在每次回覆前,把「刷貼吧」外掛留下的紀錄整理成一小段背景事實注入 system prompt:
|
package/README.zh-CN.md
CHANGED
|
@@ -94,7 +94,8 @@ npx dsh-line-bot install --no-companions
|
|
|
94
94
|
| Cloudflare Tunnel | quick/API Token 模式、Token、hostname、隧道名称、Account/Zone ID、下载 cloudflared、启停、自动设置 webhook、自行提供的公开网址 |
|
|
95
95
|
| 模型与接口来源 | 选择 LINE 回复的 provider/模型与推理强度;非 deepseek-account 时自动停用 DeepSeek 余额 |
|
|
96
96
|
| 回复人设 | 选择人设(大肥鱼或插件自定义)、新增/编辑/删除自定义人设、把目前人设复制成自定义、强制套用、允许用户自己换人设、切换大肥鱼 session 模式、测试 Agent 回复 |
|
|
97
|
-
| 贴吧学习同步 |
|
|
97
|
+
| 贴吧学习同步 | 把刷贴吧学到的语气写回人设(内置人设用覆盖层)、预览差异、立即同步 |
|
|
98
|
+
| 玩梗 | 文字梗库:内置种子、贴吧金句、wanjiqi 弹幕梗;三个来源可分别开关、每轮注入几条、新增/删除梗 |
|
|
98
99
|
| 个人记忆 | 每位用户分开的长期记忆、到期自动删除、每日请角色整理、手动删除 |
|
|
99
100
|
| 表情包 | 使用 dsh-meme 图库回图片/贴图、开关、每次最多几张、图库样本 |
|
|
100
101
|
| 贴图理解 | 收到 LINE 贴图时抓图给模型看(可关闭) |
|
|
@@ -160,12 +161,24 @@ Bot 被加入群组(或多人聊天室)后:
|
|
|
160
161
|
|
|
161
162
|
「刷贴吧」插件每天最多演化一次自己的语气(存在 `<DSH_HOME>/tieba-duty/persona.json`),这和 LINE 用的大肥鱼人设(`<DSH_HOME>/storages/dsh-persona-dafeiyu/personas.json`)原本是两套、互不相通。这个插件可以把它们接起来:
|
|
162
163
|
|
|
163
|
-
- 面板「回复人设 →
|
|
164
|
+
- 面板「回复人设 → 贴吧学习同步」可设置开关、同步目标人设(可选大肥鱼的人设,含内置)、检查间隔(默认 60 分钟)、是否同步性格数值、写入后是否重载大肥鱼插件。内置人设(大肥鱼/阿茶/听澜)不能改原文,会写入 `<DSH_HOME>/line-bot/persona-overlays/<id>.md` 覆盖层,每轮注入、不用重载;自订人设才直接改本文。
|
|
164
165
|
- 每次检查会比对贴吧人设版本;有新版本才动作。写入时只替换目标人设正文中的 `## 貼吧學來的口氣` 段落(没有就附加),不会覆盖其他手写内容;性格数值(温柔/毒舌/抽象/话痨/发图倾向)可一并更新。
|
|
165
166
|
- 写入前会备份到 `<DSH_HOME>/line-bot/persona-backups/personas-<时间>.json`。
|
|
166
167
|
- 写入后默认会通过 Cordis loader 重启 `dsh-persona-dafeiyu`,让它重新读取 store;重启失败只会记警告,人设文字仍已写入,下次插件重载时生效。
|
|
167
168
|
- 贴吧插件若没在跑、或贴吧人设不存在,检查会跳过(`pending` 显示尚未同步的原因)。
|
|
168
169
|
|
|
170
|
+
## 玩梗(文字梗)
|
|
171
|
+
|
|
172
|
+
面板「玩梗」让角色自然用一点文字梗,三个来源可分别开关:
|
|
173
|
+
|
|
174
|
+
- **内置种子**:10 条常见网络梗,附使用时机。
|
|
175
|
+
- **贴吧金句**:从「刷贴吧」的风格库导入高赞短句。
|
|
176
|
+
- **wanjiqi 梗库**:6657 直播间弹幕梗(可选;风格较重,默认关闭)。
|
|
177
|
+
|
|
178
|
+
每轮只带入几条给角色参考,一次回复最多用一个;提示词要求不要解释梗、不要硬塞、正经或难过时不要用。想自己加梗,在卡片下方填写「梗」与「用法」后按「新增到梗库」;梗库位于 `<DSH_HOME>/line-bot/jokes.json`。
|
|
179
|
+
|
|
180
|
+
「加入梗类贴吧」会把弱智吧、抽象吧、搞笑吧、梗吧加入刷贴吧的 bars(会先备份设置);要让它真的去刷,仍需要在刷贴吧那边启动。
|
|
181
|
+
|
|
169
182
|
## 贴吧动态(让角色知道自己刚刷过)
|
|
170
183
|
|
|
171
184
|
面板「进阶设置 → 让 Agent 知道贴吧动态」(默认开)会在每次回复前,把「刷贴吧」插件留下的记录整理成一小段背景事实注入 system prompt:
|
package/client.js
CHANGED
|
@@ -18,6 +18,7 @@ window.__ModuleLoader__.load({
|
|
|
18
18
|
{ key: 'model', label: '模型與來源' },
|
|
19
19
|
{ key: 'persona', label: '回覆人設' },
|
|
20
20
|
{ key: 'sync', label: '貼吧同步' },
|
|
21
|
+
{ key: 'jokes', label: '玩梗' },
|
|
21
22
|
{ key: 'memory', label: '個人記憶' },
|
|
22
23
|
{ key: 'balance', label: 'DeepSeek 餘額' },
|
|
23
24
|
{ key: 'nudge', label: '主動傳訊' },
|
|
@@ -353,6 +354,7 @@ window.__ModuleLoader__.load({
|
|
|
353
354
|
const [toast, setToast] = useState(null)
|
|
354
355
|
const [active, setActive] = useState('overview')
|
|
355
356
|
const [syncPreview, setSyncPreview] = useState(null)
|
|
357
|
+
const [jokeDraft, setJokeDraft] = useState({ text: '', usage: '' })
|
|
356
358
|
const [balancePreview, setBalancePreview] = useState(null)
|
|
357
359
|
const [memoryData, setMemoryData] = useState(null)
|
|
358
360
|
const [modelCatalog, setModelCatalog] = useState(null)
|
|
@@ -659,6 +661,33 @@ window.__ModuleLoader__.load({
|
|
|
659
661
|
}
|
|
660
662
|
}
|
|
661
663
|
|
|
664
|
+
async function addJoke() {
|
|
665
|
+
const text = String(jokeDraft.text || '').trim()
|
|
666
|
+
if (!text) {
|
|
667
|
+
setToast({ kind: 'error', text: '請先填一個梗' })
|
|
668
|
+
return
|
|
669
|
+
}
|
|
670
|
+
await act('joke-add', { text, usage: jokeDraft.usage })
|
|
671
|
+
setJokeDraft({ text: '', usage: '' })
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async function deleteJoke(entry) {
|
|
675
|
+
if (typeof window !== 'undefined' && !window.confirm(`刪除梗「${entry.text}」?`)) return
|
|
676
|
+
await act('joke-delete', { id: entry.id })
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function importTiebaJokes() {
|
|
680
|
+
await act('joke-import-tieba', { limit: 40 })
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async function importWanjiqiJokes() {
|
|
684
|
+
await act('joke-import-wanjiqi', {})
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
async function addTiebaBars() {
|
|
688
|
+
await act('tieba-bars-add', {})
|
|
689
|
+
}
|
|
690
|
+
|
|
662
691
|
async function refresh() {
|
|
663
692
|
setToast(null)
|
|
664
693
|
try {
|
|
@@ -706,6 +735,9 @@ window.__ModuleLoader__.load({
|
|
|
706
735
|
const groupCfg = cfg.group || {}
|
|
707
736
|
const groupList = status.groups || []
|
|
708
737
|
const memeCfg = cfg.stickers || {}
|
|
738
|
+
const jokesCfg = cfg.jokes || {}
|
|
739
|
+
const jokesStatus = status.jokes || {}
|
|
740
|
+
const jokeEntries = jokesStatus.entries || []
|
|
709
741
|
const memeStatus = status.meme || {}
|
|
710
742
|
const memeSample = Array.isArray(memeStatus.sample) ? memeStatus.sample : []
|
|
711
743
|
const balanceStatus = status.balance || {}
|
|
@@ -727,7 +759,7 @@ window.__ModuleLoader__.load({
|
|
|
727
759
|
const selectedPersona = personaCatalog.find((item) => item.id === personaCfg.id) || null
|
|
728
760
|
const selectedName = selectedPersona?.name || personaCfg.id || '大肥魚'
|
|
729
761
|
const selectedIsLocal = String(personaCfg.id || '').startsWith('local:')
|
|
730
|
-
const syncPersonaOptions =
|
|
762
|
+
const syncPersonaOptions = personaCatalog.slice()
|
|
731
763
|
const syncTarget = syncCfg.targetPersonaId || (syncPersonaOptions[0] && syncPersonaOptions[0].id) || ''
|
|
732
764
|
const bot = status.line?.bot || null
|
|
733
765
|
const webhook = status.line?.webhook || null
|
|
@@ -802,7 +834,7 @@ window.__ModuleLoader__.load({
|
|
|
802
834
|
h(
|
|
803
835
|
Card,
|
|
804
836
|
{
|
|
805
|
-
id: sectionDomId('balance'), order:
|
|
837
|
+
id: sectionDomId('balance'), order: 9,
|
|
806
838
|
title: 'DeepSeek 餘額',
|
|
807
839
|
desc: '讓角色知道 API 錢包剩多少;低於門檻時由角色自己想一句話提醒你儲值(會避開主動傳訊的靜音時段)。只有模型來源是 deepseek-account 時才適用。',
|
|
808
840
|
right: h(Btn, { onClick: () => act('balance-refresh'), disabled: !!busy || balanceStatus.applicable === false }, busy === 'balance-refresh' ? '查詢中…' : '重新查詢'),
|
|
@@ -870,7 +902,7 @@ window.__ModuleLoader__.load({
|
|
|
870
902
|
h(
|
|
871
903
|
Card,
|
|
872
904
|
{
|
|
873
|
-
id: sectionDomId('memory'), order:
|
|
905
|
+
id: sectionDomId('memory'), order: 8,
|
|
874
906
|
title: '個人記憶',
|
|
875
907
|
desc: '每位使用者分開。角色覺得重要的事會自己記下來;有期限的到期自動刪,每天也會請角色自己整理一次。',
|
|
876
908
|
right: h(Btn, { onClick: () => loadMemory(), disabled: !!busy }, busy === 'memory-list' ? '讀取中…' : '重新整理'),
|
|
@@ -1045,7 +1077,7 @@ window.__ModuleLoader__.load({
|
|
|
1045
1077
|
onChange: (event) => update({ agent: { model: event.target.value } }),
|
|
1046
1078
|
}),
|
|
1047
1079
|
h('datalist', { id: 'line-bot-model-options' },
|
|
1048
|
-
((modelProviders.find((item) => item.id === (draft.agent.provider || agentStatus.defaultProvider)) || {}).models || []).map((item) => h('option', { key: item.id, value: item.id }, item.name))),
|
|
1080
|
+
((modelProviders.find((item) => item.id === (draft.agent.provider || agentStatus.defaultProvider)) || {}).models || []).map((item) => h('option', { key: item.id, value: item.id }, item.name + (String(item.id).startsWith('local:') ? '(插件自訂)' : (item.builtin ? '(內建)' : ''))))),
|
|
1049
1081
|
),
|
|
1050
1082
|
h(Field, { label: '推理強度(選填)', hint: '留空=跟隨 provider 預設' },
|
|
1051
1083
|
h(Text, { value: draft.agent.reasoningEffort || '', onChange: (value) => update({ agent: { reasoningEffort: value } }), placeholder: 'max / high / medium' })),
|
|
@@ -1125,10 +1157,10 @@ window.__ModuleLoader__.load({
|
|
|
1125
1157
|
// 4b. 貼吧學習同步
|
|
1126
1158
|
h(
|
|
1127
1159
|
Card,
|
|
1128
|
-
{ id: sectionDomId('sync'), order: 6, title: '貼吧學習同步', desc: '
|
|
1160
|
+
{ id: sectionDomId('sync'), order: 6, title: '貼吧學習同步', desc: '把「刷貼吧」每天演化學到的語氣樣本,寫進上方人設。自訂人設會直接改人設本文;內建人設(大肥魚、阿茶、听澜)不能改原文,會寫進插件自己的覆蓋層,每輪注入、不用重載。' },
|
|
1129
1161
|
h(Toggle, { checked: syncCfg.enabled !== false, onChange: (value) => update({ sync: { enabled: value } }), label: '開啟自動同步', hint: '每小時檢查一次;貼吧人設版本有變才寫入(寫入前會備份 personas.json)' }),
|
|
1130
1162
|
h('div', { style: S.grid },
|
|
1131
|
-
h(Field, { label: '同步到哪個人設' },
|
|
1163
|
+
h(Field, { label: '同步到哪個人設', hint: '內建人設與插件自訂人設使用覆蓋層;大肥魚的自訂人設直接改本文' },
|
|
1132
1164
|
h('select', { value: syncTarget, onChange: (event) => update({ sync: { targetPersonaId: event.target.value } }), style: S.input },
|
|
1133
1165
|
syncPersonaOptions.length
|
|
1134
1166
|
? syncPersonaOptions.map((item) => h('option', { key: item.id, value: item.id }, item.name))
|
|
@@ -1152,10 +1184,51 @@ window.__ModuleLoader__.load({
|
|
|
1152
1184
|
: null,
|
|
1153
1185
|
),
|
|
1154
1186
|
|
|
1187
|
+
// 4c. 玩梗
|
|
1188
|
+
h(
|
|
1189
|
+
Card,
|
|
1190
|
+
{ id: sectionDomId('jokes'), order: 7, title: '玩梗', desc: '文字梗:內建種子、刷貼吧高讚回覆、或 wanjiqi-meme 彈幕梗。角色每輪只會看到幾條,一次回覆最多用幾個可以調;三個來源可以個別開關。' },
|
|
1191
|
+
h('div', { style: S.row },
|
|
1192
|
+
h(Badge, { ok: jokesStatus.enabled !== false }, '共 ' + (jokesStatus.total || 0) + ' 則'),
|
|
1193
|
+
h(Badge, { ok: jokesCfg.tieba !== false }, '貼吧金句'),
|
|
1194
|
+
h(Badge, { ok: jokesCfg.wanjiqi === true }, 'wanjiqi 梗'),
|
|
1195
|
+
),
|
|
1196
|
+
h('div', { style: S.row },
|
|
1197
|
+
h(Toggle, { checked: jokesCfg.enabled !== false, onChange: (value) => update({ jokes: { enabled: value } }), label: '開啟玩梗', hint: '關掉後角色看不到任何梗' }),
|
|
1198
|
+
h(Toggle, { checked: jokesCfg.tieba !== false, onChange: (value) => update({ jokes: { tieba: value } }), label: '使用貼吧金句', hint: '從刷貼吧的風格庫匯入的高讚短句' }),
|
|
1199
|
+
h(Toggle, { checked: jokesCfg.wanjiqi === true, onChange: (value) => update({ jokes: { wanjiqi: value } }), label: '使用 wanjiqi 梗庫', hint: '6657 直播間彈幕梗,風格較重,預設關閉' }),
|
|
1200
|
+
),
|
|
1201
|
+
h('div', { style: S.grid },
|
|
1202
|
+
h(Field, { label: '每輪帶入幾則', hint: '只是給角色參考,不是每則都會用' }, h(Num, { value: jokesCfg.perTurn, min: 1, max: 8, onChange: (value) => update({ jokes: { perTurn: value } }) })),
|
|
1203
|
+
h(Field, { label: '每次回覆最多用幾個', hint: '太多會很油' }, h(Num, { value: jokesCfg.maxUsePerReply, min: 1, max: 3, onChange: (value) => update({ jokes: { maxUsePerReply: value } }) })),
|
|
1204
|
+
),
|
|
1205
|
+
h('div', { style: S.row },
|
|
1206
|
+
h(Btn, { onClick: importTiebaJokes, disabled: !!busy }, busy === 'joke-import-tieba' ? '匯入中…' : '匯入貼吧金句'),
|
|
1207
|
+
h(Btn, { onClick: importWanjiqiJokes, disabled: !!busy }, busy === 'joke-import-wanjiqi' ? '下載中…' : '匯入 wanjiqi 梗庫'),
|
|
1208
|
+
h(Btn, { onClick: addTiebaBars, disabled: !!busy }, busy === 'tieba-bars-add' ? '加入中…' : '加入梗類貼吧(刷貼吧)'),
|
|
1209
|
+
),
|
|
1210
|
+
(jokesStatus.bySource || {}).tieba === 0
|
|
1211
|
+
? h('div', { style: S.note }, '貼吧金句目前 0 則:梗類貼吧(弱智吧、抽象吧、搞笑吧、梗吧)已加入刷貼吧,等它跑過一輪後再按「匯入貼吧金句」。')
|
|
1212
|
+
: null,
|
|
1213
|
+
h('div', { style: S.grid },
|
|
1214
|
+
h(Field, { label: '新增一個梗' }, h('input', { value: jokeDraft.text, onChange: (event) => setJokeDraft({ ...jokeDraft, text: event.target.value }), placeholder: '例:這波啊,這波是⋯', style: S.input })),
|
|
1215
|
+
h(Field, { label: '用法(可選)' }, h('input', { value: jokeDraft.usage, onChange: (event) => setJokeDraft({ ...jokeDraft, usage: event.target.value }), placeholder: '什麼時候用', style: S.input })),
|
|
1216
|
+
),
|
|
1217
|
+
h('div', { style: S.row }, h(Btn, { onClick: addJoke, disabled: !!busy, tone: 'primary' }, '新增到梗庫')),
|
|
1218
|
+
jokeEntries.length
|
|
1219
|
+
? h('div', { style: S.panelBox },
|
|
1220
|
+
h('div', { style: S.fieldTitle }, '梗庫(顯示 ' + Math.min(jokeEntries.length, 40) + ' / ' + jokeEntries.length + ')'),
|
|
1221
|
+
jokeEntries.slice(0, 40).map((entry) => h('div', { key: entry.id, style: { display: 'flex', gap: 8, alignItems: 'flex-start', padding: '6px 0', borderTop: '1px solid var(--dsw-alias-border-l2)' } },
|
|
1222
|
+
h('span', { style: { flex: '0 0 auto', fontSize: 11, color: 'var(--dsw-alias-label-tertiary)', paddingTop: 2 } }, entry.source || 'manual'),
|
|
1223
|
+
h('span', { style: { flex: 1, fontSize: 13, lineHeight: 1.5 } }, entry.text, entry.usage ? h('span', { style: { color: 'var(--dsw-alias-label-tertiary)' } }, '(' + entry.usage + ')') : null),
|
|
1224
|
+
h(Btn, { tone: 'danger', onClick: () => deleteJoke(entry), disabled: !!busy }, '刪除'))))
|
|
1225
|
+
: h('div', { style: S.note }, '梗庫是空的;按上面的匯入或新增。'),
|
|
1226
|
+
),
|
|
1227
|
+
|
|
1155
1228
|
// 5. 主動傳訊
|
|
1156
1229
|
h(
|
|
1157
1230
|
Card,
|
|
1158
|
-
{ id: sectionDomId('nudge'), order:
|
|
1231
|
+
{ id: sectionDomId('nudge'), order: 10, title: '主動傳訊(大肥魚自己找人聊天)', desc: '由插件內建排程器執行:每隔幾分鐘擲骰子,符合條件就請 Agent 主動傳一句;不適合時 Agent 可回 SKIP。' },
|
|
1159
1232
|
h(Toggle, { checked: !!nudgeCfg.enabled, onChange: (value) => update({ nudge: { enabled: value } }), label: '開啟主動傳訊' }),
|
|
1160
1233
|
h('div', { style: S.grid },
|
|
1161
1234
|
h(Field, { label: '每次擲骰機率(0~1)', hint: '例:0.18 = 每輪 18% 機率開口' }, h(Num, { value: nudgeCfg.chance, min: 0, max: 1, step: 0.01, onChange: (value) => update({ nudge: { chance: value } }) })),
|
|
@@ -1188,7 +1261,7 @@ window.__ModuleLoader__.load({
|
|
|
1188
1261
|
// 5.5 表情包(dsh-meme 圖庫)
|
|
1189
1262
|
h(
|
|
1190
1263
|
Card,
|
|
1191
|
-
{ id: sectionDomId('meme'), order:
|
|
1264
|
+
{ id: sectionDomId('meme'), order: 11, title: '表情包(dsh-meme 圖庫)', desc: '角色可以在文字後面配一張圖,也可以只回一張圖。圖片來自 dsh-meme 外掛的圖庫;要新增、分類或改描述,請到 dsh-meme 的設定頁。' },
|
|
1192
1265
|
!memeStatus.available
|
|
1193
1266
|
? h('div', { style: S.warnBox }, '沒有偵測到 dsh-meme 外掛(或尚未啟用)。請先安裝並啟用 dsh-meme,這裡才會出現可用圖片。')
|
|
1194
1267
|
: h('div', null,
|
|
@@ -1213,7 +1286,7 @@ window.__ModuleLoader__.load({
|
|
|
1213
1286
|
// 6. 使用者
|
|
1214
1287
|
h(
|
|
1215
1288
|
Card,
|
|
1216
|
-
{ id: sectionDomId('users'), order:
|
|
1289
|
+
{ id: sectionDomId('users'), order: 12, title: '使用者', desc: '與 bot 互動過的 LINE 使用者。每個人有自己的 DSH session 與工作資料夾(記憶、檔案不互通),人設也可以個別指定。' },
|
|
1217
1290
|
(status.users || []).length === 0
|
|
1218
1291
|
? h('div', { style: S.hint }, '還沒有人傳訊息給 bot。')
|
|
1219
1292
|
: h('table', { style: S.table },
|
|
@@ -1249,7 +1322,7 @@ window.__ModuleLoader__.load({
|
|
|
1249
1322
|
// 6.5 群組
|
|
1250
1323
|
h(
|
|
1251
1324
|
Card,
|
|
1252
|
-
{ id: sectionDomId('group'), order:
|
|
1325
|
+
{ id: sectionDomId('group'), order: 13, title: '群組', desc: '被加進群組時,只有被 @、被引用,或依機率擲骰才回覆;每個群組有自己的 session、工作資料夾、人設與記憶;比較晚加入時可以匯入 LINE 匯出的聊天記錄補記憶。' },
|
|
1253
1326
|
h('input', { ref: importInput, type: 'file', accept: '.txt,text/plain', style: { display: 'none' }, onChange: importChatFile }),
|
|
1254
1327
|
h('div', { style: S.grid },
|
|
1255
1328
|
h(Field, { label: '沒被點名時的回覆機率(%)', hint: '每則群組訊息用這個機率決定要不要回;0 就只在被 @ 或引用時回' },
|
package/lib/agent-runner.mjs
CHANGED
|
@@ -82,7 +82,7 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
|
|
|
82
82
|
|
|
83
83
|
// 把「插件自己的人設」與「貼吧動態」掛進這個 agent 的 system prompt。
|
|
84
84
|
// 自訂人設只有選了才注入;大肥魚的人設走它自己的 session 指定。
|
|
85
|
-
function agentSetup({ personaText, contextText, tools = [] }) {
|
|
85
|
+
function agentSetup({ personaText, overlayText, contextText, tools = [] }) {
|
|
86
86
|
return (agentCtx) => {
|
|
87
87
|
try {
|
|
88
88
|
const prompt = agentCtx?.systemPrompt
|
|
@@ -123,6 +123,17 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
|
|
|
123
123
|
'line-bot: persona section',
|
|
124
124
|
)
|
|
125
125
|
}
|
|
126
|
+
if (overlayText) {
|
|
127
|
+
agentCtx.effect(
|
|
128
|
+
() => prompt.section({
|
|
129
|
+
name: 'line-bot:persona-overlay',
|
|
130
|
+
order: order + 10,
|
|
131
|
+
text: overlayText,
|
|
132
|
+
interpolate: false,
|
|
133
|
+
}),
|
|
134
|
+
'line-bot: persona overlay section',
|
|
135
|
+
)
|
|
136
|
+
}
|
|
126
137
|
if (contextText) {
|
|
127
138
|
agentCtx.effect(
|
|
128
139
|
() => prompt.section({
|
|
@@ -203,7 +214,7 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
|
|
|
203
214
|
}
|
|
204
215
|
}
|
|
205
216
|
|
|
206
|
-
async function turn({ sessionId, text, persona, personaClient, personaText, contextText, tools = [], images = [], cwd: cwdOverride }) {
|
|
217
|
+
async function turn({ sessionId, text, persona, personaClient, personaText, overlayText, contextText, tools = [], images = [], cwd: cwdOverride }) {
|
|
207
218
|
const cfg = getConfig()
|
|
208
219
|
const timeoutMs = cfg.agent.timeoutMs || 180000
|
|
209
220
|
const { options } = agentOptions()
|
|
@@ -217,7 +228,7 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
|
|
|
217
228
|
let offEnd = null
|
|
218
229
|
|
|
219
230
|
try {
|
|
220
|
-
const setup = personaText || contextText || tools.length > 0 ? agentSetup({ personaText, contextText, tools }) : undefined
|
|
231
|
+
const setup = personaText || overlayText || contextText || tools.length > 0 ? agentSetup({ personaText, overlayText, contextText, tools }) : undefined
|
|
221
232
|
const opened = await openAgent(sessionId, options, cwd, setup)
|
|
222
233
|
agent = opened.agent
|
|
223
234
|
handle = opened.handle
|
package/lib/config.mjs
CHANGED
|
@@ -80,9 +80,10 @@ export const DEFAULT_CONFIG = {
|
|
|
80
80
|
tiebaContext: true,
|
|
81
81
|
},
|
|
82
82
|
sync: {
|
|
83
|
-
//
|
|
83
|
+
// 每天把刷貼吧學到的語氣寫回指定的人設。內建人設(大肥魚等)不能改原文,
|
|
84
|
+
// 會寫進 <DSH_HOME>/line-bot/persona-overlays/<id>.md 覆蓋層;自訂人設才改本文。
|
|
84
85
|
enabled: false,
|
|
85
|
-
targetPersonaId: '',
|
|
86
|
+
targetPersonaId: 'dafeiyu',
|
|
86
87
|
intervalMinutes: 60,
|
|
87
88
|
syncTraits: true,
|
|
88
89
|
reloadPersona: true,
|
|
@@ -118,6 +119,14 @@ export const DEFAULT_CONFIG = {
|
|
|
118
119
|
reviewDaily: true,
|
|
119
120
|
reviewHour: 4,
|
|
120
121
|
},
|
|
122
|
+
jokes: {
|
|
123
|
+
// 文字梗:從內建種子、刷貼吧高讚回覆、或 wanjiqi-meme 匯入。
|
|
124
|
+
enabled: true,
|
|
125
|
+
perTurn: 3,
|
|
126
|
+
maxUsePerReply: 1,
|
|
127
|
+
tieba: true,
|
|
128
|
+
wanjiqi: false,
|
|
129
|
+
},
|
|
121
130
|
stickers: {
|
|
122
131
|
// 角色可以用圖片/表情包回話;圖片由面板上傳,存在 <dataDir>/stickers。
|
|
123
132
|
enabled: true,
|
|
@@ -227,7 +236,7 @@ export function sanitizePatch(current, patch) {
|
|
|
227
236
|
|
|
228
237
|
const sync = next.sync
|
|
229
238
|
sync.enabled = Boolean(sync.enabled)
|
|
230
|
-
sync.targetPersonaId = String(sync.targetPersonaId || '').trim()
|
|
239
|
+
sync.targetPersonaId = String(sync.targetPersonaId || '').trim() || 'dafeiyu'
|
|
231
240
|
sync.intervalMinutes = clampInt(sync.intervalMinutes, 5, 1440, 60)
|
|
232
241
|
sync.syncTraits = Boolean(sync.syncTraits)
|
|
233
242
|
sync.reloadPersona = Boolean(sync.reloadPersona)
|
|
@@ -247,6 +256,13 @@ export function sanitizePatch(current, patch) {
|
|
|
247
256
|
memory.injectMaxChars = clampInt(memory.injectMaxChars, 200, 8000, 2000)
|
|
248
257
|
memory.reviewHour = clampInt(memory.reviewHour, 0, 23, 4)
|
|
249
258
|
|
|
259
|
+
const jokes = next.jokes || (next.jokes = {})
|
|
260
|
+
jokes.enabled = Boolean(jokes.enabled)
|
|
261
|
+
jokes.perTurn = clampInt(jokes.perTurn, 1, 8, 3)
|
|
262
|
+
jokes.maxUsePerReply = clampInt(jokes.maxUsePerReply, 1, 3, 1)
|
|
263
|
+
jokes.tieba = Boolean(jokes.tieba)
|
|
264
|
+
jokes.wanjiqi = Boolean(jokes.wanjiqi)
|
|
265
|
+
|
|
250
266
|
const stickers = next.stickers || (next.stickers = {})
|
|
251
267
|
stickers.enabled = Boolean(stickers.enabled)
|
|
252
268
|
stickers.maxPerReply = clampInt(stickers.maxPerReply, 1, 3, 1)
|
package/lib/host.mjs
CHANGED
|
@@ -23,27 +23,29 @@ const diagnosticsMod = await import(withVersion('./diagnostics.mjs'))
|
|
|
23
23
|
const tiebaContextMod = await import(withVersion('./tieba-context.mjs'))
|
|
24
24
|
const balanceMod = await import(withVersion('./deepseek-balance.mjs'))
|
|
25
25
|
const memoryMod = await import(withVersion('./memory.mjs'))
|
|
26
|
+
const jokesMod = await import(withVersion('./jokes.mjs'))
|
|
26
27
|
const stickersMod = await import(withVersion('./stickers.mjs'))
|
|
27
28
|
const memeSourceMod = await import(withVersion('./meme-source.mjs'))
|
|
28
29
|
const stickerImageMod = await import(withVersion('./sticker-image.mjs'))
|
|
29
30
|
const utilMod = await import(withVersion('./util.mjs'))
|
|
30
|
-
const { createConfigStore, publicConfig, dataDir } = configMod
|
|
31
|
+
const { createConfigStore, publicConfig, dataDir, dshHome } = configMod
|
|
31
32
|
const { createPersonaClient } = personaMod
|
|
32
33
|
const { createAgentRunner } = runnerMod
|
|
33
34
|
const { createWebhookServer } = webhookMod
|
|
34
35
|
const { createTunnelManager } = tunnelMod
|
|
35
36
|
const { createNudgeScheduler } = nudgeMod
|
|
36
|
-
const { createTiebaSync, reloadPersonaPlugin } = tiebaSyncMod
|
|
37
|
+
const { createTiebaSync, reloadPersonaPlugin, readPersonaOverlay, ensureTiebaBars, readTiebaStyleSamples } = tiebaSyncMod
|
|
37
38
|
const { createLocalPersonas, LOCAL_PREFIX } = localPersonasMod
|
|
38
39
|
const { buildTiebaContext } = tiebaContextMod
|
|
39
40
|
const { createBalanceMonitor } = balanceMod
|
|
40
41
|
const { createMemoryStore } = memoryMod
|
|
42
|
+
const { createJokeStore } = jokesMod
|
|
41
43
|
const { createStickerStore } = stickersMod
|
|
42
44
|
const { createMemeSource } = memeSourceMod
|
|
43
45
|
const { createStickerImages } = stickerImageMod
|
|
44
|
-
const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody, safeArray, sleep } = utilMod
|
|
46
|
+
const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody, safeArray, sleep, readJsonSafe } = utilMod
|
|
45
47
|
|
|
46
|
-
export const VERSION = '0.
|
|
48
|
+
export const VERSION = '0.9.1'
|
|
47
49
|
|
|
48
50
|
// 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
|
|
49
51
|
// 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
|
|
@@ -105,6 +107,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
105
107
|
const logger = makeLogger(dir, upstream)
|
|
106
108
|
const store = createConfigStore({ logger })
|
|
107
109
|
const localPersonas = createLocalPersonas({ dir, logger })
|
|
110
|
+
const jokes = createJokeStore({ dir, logger })
|
|
108
111
|
const startedAt = Date.now()
|
|
109
112
|
|
|
110
113
|
const runtime = {
|
|
@@ -158,12 +161,23 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
158
161
|
} catch (error) {
|
|
159
162
|
logger.warn(`請大肥魚讓開失敗(自訂人設仍會注入):${errorText(error)}`)
|
|
160
163
|
}
|
|
161
|
-
return { persona: null, personaText: resolved.text, source: 'local', name: resolved.name }
|
|
164
|
+
return { persona: null, personaText: resolved.text, source: 'local', name: resolved.name, selection: resolved.id }
|
|
162
165
|
}
|
|
163
166
|
if (resolved.kind === 'dafeiyu') {
|
|
164
|
-
return { persona: resolved.id, personaText: null, source: 'dafeiyu', name: '' }
|
|
167
|
+
return { persona: resolved.id, personaText: null, source: 'dafeiyu', name: '', selection: resolved.id }
|
|
168
|
+
}
|
|
169
|
+
return { persona: null, personaText: null, source: '', name: '', selection: '' }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 內建人設(大肥魚等)不能改原文,貼吧同步會寫在覆蓋層;每輪注入在 persona 之後。
|
|
173
|
+
function personaOverlayText(applied) {
|
|
174
|
+
const id = applied?.selection || applied?.persona
|
|
175
|
+
if (!id) return ''
|
|
176
|
+
try {
|
|
177
|
+
return readPersonaOverlay(id)
|
|
178
|
+
} catch {
|
|
179
|
+
return ''
|
|
165
180
|
}
|
|
166
|
-
return { persona: null, personaText: null, source: '', name: '' }
|
|
167
181
|
}
|
|
168
182
|
|
|
169
183
|
// 每輪把貼吧活動摘要塞進 system prompt;面板可關閉(agent.tiebaContext)。
|
|
@@ -320,9 +334,10 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
320
334
|
}
|
|
321
335
|
|
|
322
336
|
function agentContextText(ownerKey, kind = 'user', extra = '') {
|
|
337
|
+
const jokeText = jokes.contextText(() => store.get())
|
|
323
338
|
// 群組不注入貼吧作息,避免在群組裡突然講起刷貼吧的事。
|
|
324
339
|
const tieba = kind === 'user' ? tiebaContextText() : ''
|
|
325
|
-
return [modelContextText(), tieba, balanceContextText(), extra, memoryContextText(ownerKey), memeContextText() || stickerContextText()].filter(Boolean).join('\n\n')
|
|
340
|
+
return [modelContextText(), jokeText, tieba, balanceContextText(), extra, memoryContextText(ownerKey), memeContextText() || stickerContextText()].filter(Boolean).join('\n\n')
|
|
326
341
|
}
|
|
327
342
|
|
|
328
343
|
async function refreshBalance() {
|
|
@@ -366,6 +381,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
366
381
|
text: promptText,
|
|
367
382
|
persona: applied.persona,
|
|
368
383
|
personaText: applied.personaText,
|
|
384
|
+
overlayText: personaOverlayText(applied),
|
|
369
385
|
personaClient: persona(),
|
|
370
386
|
contextText: '',
|
|
371
387
|
cwd: ensureUserWorkspace(userId) || undefined,
|
|
@@ -455,6 +471,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
455
471
|
text: promptText,
|
|
456
472
|
persona: applied.persona,
|
|
457
473
|
personaText: applied.personaText,
|
|
474
|
+
overlayText: personaOverlayText(applied),
|
|
458
475
|
personaClient: persona(),
|
|
459
476
|
contextText: '',
|
|
460
477
|
tools: memoryToolsFor(id),
|
|
@@ -1136,6 +1153,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1136
1153
|
'- 清單裡沒有適合的圖就不要硬挑,改用文字;想新增圖片請主人到 dsh-meme 的設定/圖庫頁加入,你這邊不能新增圖庫內容。',
|
|
1137
1154
|
'- 不要呼叫 send_meme(LINE 沒有連到它,呼叫了也不會送出圖片),也不要寫 <meme#N> 或 [表情:…] 這種格式。',
|
|
1138
1155
|
'- 標記放最後;不要在旁邊解釋圖片內容,也不要說「我傳了貼圖」。',
|
|
1156
|
+
'- 主人明確說「不要圖」「只要文字」時,就不要加任何標記。',
|
|
1139
1157
|
'- 只回圖也可以:整則回覆只放標記。',
|
|
1140
1158
|
`- 一次最多 ${memeMaxPerReply()} 張,編號必須是 1–${lines.length}。`,
|
|
1141
1159
|
'- 氣氛明顯(開心、無語、害羞、想睡、被逗笑)才用;一般聊天、正事、難過時不要用;對方明確要表情包時就用。',
|
|
@@ -1546,6 +1564,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1546
1564
|
text,
|
|
1547
1565
|
persona: applied.persona,
|
|
1548
1566
|
personaText: applied.personaText,
|
|
1567
|
+
overlayText: personaOverlayText(applied),
|
|
1549
1568
|
personaClient: persona(),
|
|
1550
1569
|
contextText: agentContextText(
|
|
1551
1570
|
memoryKey,
|
|
@@ -1642,6 +1661,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1642
1661
|
text,
|
|
1643
1662
|
persona: applied.persona,
|
|
1644
1663
|
personaText: applied.personaText,
|
|
1664
|
+
overlayText: personaOverlayText(applied),
|
|
1645
1665
|
personaClient: persona(),
|
|
1646
1666
|
contextText: agentContextText(owner?.[0]),
|
|
1647
1667
|
tools: memoryToolsFor(owner?.[0]),
|
|
@@ -1651,10 +1671,37 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1651
1671
|
sendProactive: sendPush,
|
|
1652
1672
|
})
|
|
1653
1673
|
|
|
1654
|
-
const tiebaSync = createTiebaSync({ ctx, store, logger })
|
|
1674
|
+
const tiebaSync = createTiebaSync({ ctx, store, logger, getCatalog: async () => (await getPersonaSummary({ force: true })).catalog })
|
|
1655
1675
|
|
|
1656
1676
|
// ── 人設摘要(給面板) ───────────────────────────────────────────────────
|
|
1657
1677
|
let personaCache = { at: 0, value: null }
|
|
1678
|
+
function readDafeiyuStoreFallback() {
|
|
1679
|
+
const builtins = [
|
|
1680
|
+
{ id: 'dafeiyu', name: '大肥魚', chars: 635 },
|
|
1681
|
+
{ id: 'acha', name: '阿茶', chars: 979 },
|
|
1682
|
+
{ id: 'tinglan', name: '听澜', chars: 1738 },
|
|
1683
|
+
].map((item) => ({ ...item, builtin: true, enabled: true, description: '', source: 'dafeiyu' }))
|
|
1684
|
+
const list = builtins
|
|
1685
|
+
try {
|
|
1686
|
+
const file = path.join(dshHome(), 'storages', 'dsh-persona-dafeiyu', 'personas.json')
|
|
1687
|
+
const raw = readJsonSafe(file, null)
|
|
1688
|
+
for (const item of raw?.personas || []) {
|
|
1689
|
+
const id = String(item?.id || '').trim()
|
|
1690
|
+
if (!id) continue
|
|
1691
|
+
list.push({
|
|
1692
|
+
id,
|
|
1693
|
+
name: String(item.name || id),
|
|
1694
|
+
builtin: false,
|
|
1695
|
+
enabled: true,
|
|
1696
|
+
chars: String(item.text || '').length,
|
|
1697
|
+
description: '直接從 personas.json 讀取',
|
|
1698
|
+
source: 'dafeiyu',
|
|
1699
|
+
})
|
|
1700
|
+
}
|
|
1701
|
+
} catch { /* 讀不到就只列內建 */ }
|
|
1702
|
+
return list
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1658
1705
|
async function getPersonaSummary({ force = false } = {}) {
|
|
1659
1706
|
const cfg = store.get()
|
|
1660
1707
|
if (!force && personaCache.value && Date.now() - personaCache.at < 5000) return personaCache.value
|
|
@@ -1702,7 +1749,11 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1702
1749
|
]
|
|
1703
1750
|
} catch (error) {
|
|
1704
1751
|
summary.error = errorText(error)
|
|
1705
|
-
|
|
1752
|
+
const fallback = readDafeiyuStoreFallback()
|
|
1753
|
+
summary.list = fallback
|
|
1754
|
+
summary.mode = 'unknown'
|
|
1755
|
+
summary.catalog = [...fallback, ...local]
|
|
1756
|
+
summary.warning = '讀不到大肥魚外掛的 HTTP bridge,已直接讀取 personas.json;清單可能較舊,寫入後請重啟大肥魚外掛。'
|
|
1706
1757
|
}
|
|
1707
1758
|
const selected = summary.catalog.find((item) => item.id === cfg.persona.id)
|
|
1708
1759
|
summary.selectedName = selected?.name || ''
|
|
@@ -1710,7 +1761,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1710
1761
|
summary.warning = selectedSource === 'local'
|
|
1711
1762
|
? `找不到自訂人設「${cfg.persona.id}」,LINE 會改用預設人設。`
|
|
1712
1763
|
: `找不到人設「${cfg.persona.id}」,LINE 會退回大肥魚的全域人設。`
|
|
1713
|
-
} else if (selectedSource === 'dafeiyu' && cfg.persona.enforce && summary.mode !== 'session') {
|
|
1764
|
+
} else if (!summary.error && selectedSource === 'dafeiyu' && cfg.persona.enforce && summary.mode !== 'session') {
|
|
1714
1765
|
summary.warning = `大肥魚目前是「${summary.mode || '未知'}」模式;LINE 的逐 session 人設指定要切到 session 模式才會生效。`
|
|
1715
1766
|
}
|
|
1716
1767
|
personaCache = { at: Date.now(), value: summary }
|
|
@@ -1995,6 +2046,14 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
1995
2046
|
injectMaxChars: Number(cfg.memory?.injectMaxChars) || 2000,
|
|
1996
2047
|
lastReviewAt: Number(state.memory?.lastReviewAt) || 0,
|
|
1997
2048
|
},
|
|
2049
|
+
jokes: {
|
|
2050
|
+
...jokes.stats(),
|
|
2051
|
+
enabled: cfg.jokes?.enabled !== false,
|
|
2052
|
+
tieba: cfg.jokes?.tieba !== false,
|
|
2053
|
+
wanjiqi: cfg.jokes?.wanjiqi === true,
|
|
2054
|
+
perTurn: Number(cfg.jokes?.perTurn) || 3,
|
|
2055
|
+
entries: jokes.list().slice(0, 200),
|
|
2056
|
+
},
|
|
1998
2057
|
sync: { ...tiebaSync.stats(), config: cfg.sync },
|
|
1999
2058
|
users,
|
|
2000
2059
|
groups,
|
|
@@ -2098,6 +2157,45 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
2098
2157
|
return tiebaSync.preview()
|
|
2099
2158
|
case 'tieba-sync':
|
|
2100
2159
|
return tiebaSync.run({ force: true })
|
|
2160
|
+
case 'tieba-bars-add': {
|
|
2161
|
+
const bars = Array.isArray(payload.bars) && payload.bars.length > 0
|
|
2162
|
+
? payload.bars.map((item) => String(item || '').trim()).filter(Boolean)
|
|
2163
|
+
: ['弱智吧', '抽象吧', '搞笑吧', '梗吧']
|
|
2164
|
+
const result = ensureTiebaBars(bars)
|
|
2165
|
+
logger.info(`刷貼吧設定已加入梗類貼吧:${result.added.join('、') || '(都已經在了)'}`)
|
|
2166
|
+
return result
|
|
2167
|
+
}
|
|
2168
|
+
case 'joke-list':
|
|
2169
|
+
return { ...jokes.stats(), entries: jokes.list() }
|
|
2170
|
+
case 'joke-add':
|
|
2171
|
+
return jokes.add({
|
|
2172
|
+
text: payload.text,
|
|
2173
|
+
usage: payload.usage,
|
|
2174
|
+
mood: payload.mood,
|
|
2175
|
+
source: 'manual',
|
|
2176
|
+
})
|
|
2177
|
+
case 'joke-update':
|
|
2178
|
+
return jokes.update(String(payload.id || ''), payload)
|
|
2179
|
+
case 'joke-delete':
|
|
2180
|
+
return jokes.remove(String(payload.id || ''))
|
|
2181
|
+
case 'joke-import-tieba': {
|
|
2182
|
+
const items = readTiebaStyleSamples({ limit: Math.max(1, Math.min(100, Number(payload.limit) || 40)) })
|
|
2183
|
+
const removed = jokes.removeBySource('tieba')
|
|
2184
|
+
const added = jokes.importMany('tieba', items)
|
|
2185
|
+
return { added, removed, scanned: items.length, source: 'tieba' }
|
|
2186
|
+
}
|
|
2187
|
+
case 'joke-import-wanjiqi': {
|
|
2188
|
+
const response = await fetch('https://raw.githubusercontent.com/Chu-Xin-r/wanjiqi-meme/main/skills/wanjiqi-meme/assets/memes_top100.json')
|
|
2189
|
+
if (!response.ok) throw new Error(`下載 wanjiqi 梗庫失敗:HTTP ${response.status}`)
|
|
2190
|
+
const raw = await response.json()
|
|
2191
|
+
const items = (Array.isArray(raw) ? raw : [])
|
|
2192
|
+
.map((item) => String(item?.barrage || '').replace(/\s+/g, ' ').trim())
|
|
2193
|
+
.filter((text) => text.length >= 4 && text.length <= 40)
|
|
2194
|
+
.map((text) => ({ text, usage: '玩机器 6657 直播間彈幕梗(來源:wanjiqi-meme)' }))
|
|
2195
|
+
const removed = jokes.removeBySource('wanjiqi')
|
|
2196
|
+
const added = jokes.importMany('wanjiqi', items, { enabled: true })
|
|
2197
|
+
return { added, removed, scanned: items.length, source: 'wanjiqi' }
|
|
2198
|
+
}
|
|
2101
2199
|
case 'persona-save': {
|
|
2102
2200
|
const id = String(payload.id || '').trim()
|
|
2103
2201
|
if (!id) throw new Error('缺少人設 id')
|
|
@@ -2264,6 +2362,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
|
|
|
2264
2362
|
text,
|
|
2265
2363
|
persona: applied.persona,
|
|
2266
2364
|
personaText: applied.personaText,
|
|
2365
|
+
overlayText: personaOverlayText(applied),
|
|
2267
2366
|
personaClient: persona(),
|
|
2268
2367
|
contextText: agentContextText(testUserId),
|
|
2269
2368
|
tools: memoryToolsFor(testUserId),
|
package/lib/jokes.mjs
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { randomBytes } from 'node:crypto'
|
|
4
|
+
import { readJsonSafe, writeJsonAtomic, clip } from './util.mjs'
|
|
5
|
+
|
|
6
|
+
const MAX_TEXT = 120
|
|
7
|
+
const MAX_USAGE = 160
|
|
8
|
+
const MAX_ENTRIES = 400
|
|
9
|
+
|
|
10
|
+
const DEFAULTS = [
|
|
11
|
+
{ text: '這波啊,這波是⋯', usage: '遇到離譜的操作或劇情反轉時,接一句吐槽', mood: 'amused' },
|
|
12
|
+
{ text: '典', usage: '對方講了一長串很典型的套路或偏見,一個字收掉', mood: 'dry' },
|
|
13
|
+
{ text: '急了', usage: '對方被戳到跳腳、開始連珠炮時', mood: 'teasing' },
|
|
14
|
+
{ text: '破防了', usage: '被一句話戳中痛點,帶點自嘲', mood: 'self-deprecating' },
|
|
15
|
+
{ text: '你禮貌嗎', usage: '對方講話欠揍但又不到翻臉時', mood: 'teasing' },
|
|
16
|
+
{ text: '離譜', usage: '事情荒唐到不知道從哪吐槽時', mood: 'amused' },
|
|
17
|
+
{ text: '樂', usage: '看熱鬧、覺得好笑但不想長篇大論時', mood: 'amused' },
|
|
18
|
+
{ text: '有內味了', usage: '某個場景或語氣很像某種刻板印象時', mood: 'dry' },
|
|
19
|
+
{ text: '泰褲辣', usage: '反諷式誇張稱讚,語氣要明顯在鬧', mood: 'playful' },
|
|
20
|
+
{ text: '屬於是', usage: '把對方的行為歸類成一個荒謬結論時', mood: 'dry' },
|
|
21
|
+
{ text: '好傢伙', usage: '看到離譜場面時的驚嘆,簡短收尾', mood: 'amused' },
|
|
22
|
+
{ text: '不然呢', usage: '對方問了答案很明顯的問題時', mood: 'dry' },
|
|
23
|
+
{ text: '血壓上來了', usage: '被氣到但又還在開玩笑的範圍內', mood: 'amused' },
|
|
24
|
+
{ text: '蚌埠住了', usage: '忍不住笑出來或撐不住時(「崩不住了」的諧音)', mood: 'amused' },
|
|
25
|
+
{ text: '拴Q', usage: '無奈道謝或自嘲式感謝(thank you 諧音)', mood: 'playful' },
|
|
26
|
+
{ text: '芭比Q了', usage: '事情搞砸、完蛋了的輕鬆說法', mood: 'amused' },
|
|
27
|
+
{ text: '退退退', usage: '開玩笑地把麻煩或推銷趕走', mood: 'playful' },
|
|
28
|
+
{ text: '我不理解', usage: '遇到無法理解的邏輯時,淡淡一句', mood: 'confused' },
|
|
29
|
+
{ text: '這是可以說的嗎', usage: '對方講了很敢講的話時,假裝驚訝', mood: 'teasing' },
|
|
30
|
+
{ text: '笑死', usage: '真的很好笑,但不想打長串時', mood: 'amused' },
|
|
31
|
+
{ text: '離大譜', usage: '比「離譜」再誇張一點的版本', mood: 'amused' },
|
|
32
|
+
{ text: '真有你的', usage: '對方做了讓人哭笑不得的事時', mood: 'teasing' },
|
|
33
|
+
{ text: '懂了但沒完全懂', usage: '大概抓到意思、細節還模糊時', mood: 'confused' },
|
|
34
|
+
{ text: '抽象', usage: '某件事荒謬到難以形容時(語氣要輕,別用來罵人)', mood: 'dry' },
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
function nowIso() {
|
|
38
|
+
return new Date().toISOString()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeText(value) {
|
|
42
|
+
return String(value || '').replace(/\s+/g, ' ').trim()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createJokeStore({ dir, logger }) {
|
|
46
|
+
const file = path.join(dir, 'jokes.json')
|
|
47
|
+
let data = readJsonSafe(file, null)
|
|
48
|
+
|
|
49
|
+
if (!data || !Array.isArray(data.entries)) {
|
|
50
|
+
data = {
|
|
51
|
+
version: 1,
|
|
52
|
+
entries: DEFAULTS.map((item) => ({
|
|
53
|
+
id: 'j_' + randomBytes(4).toString('hex'),
|
|
54
|
+
text: item.text,
|
|
55
|
+
usage: item.usage,
|
|
56
|
+
mood: item.mood,
|
|
57
|
+
source: 'seed',
|
|
58
|
+
enabled: true,
|
|
59
|
+
createdAt: nowIso(),
|
|
60
|
+
})),
|
|
61
|
+
}
|
|
62
|
+
writeJsonAtomic(file, data)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 既有安裝也要補上新增的內建種子(只補缺少的文字,不動使用者自己加的)。
|
|
66
|
+
{
|
|
67
|
+
const have = new Set(data.entries.filter((item) => item.source === 'seed').map((item) => item.text))
|
|
68
|
+
let changed = false
|
|
69
|
+
for (const item of DEFAULTS) {
|
|
70
|
+
if (have.has(item.text)) continue
|
|
71
|
+
data.entries.push({
|
|
72
|
+
id: 'j_' + randomBytes(4).toString('hex'),
|
|
73
|
+
text: item.text,
|
|
74
|
+
usage: item.usage,
|
|
75
|
+
mood: item.mood,
|
|
76
|
+
source: 'seed',
|
|
77
|
+
enabled: true,
|
|
78
|
+
createdAt: nowIso(),
|
|
79
|
+
})
|
|
80
|
+
changed = true
|
|
81
|
+
}
|
|
82
|
+
if (changed) writeJsonAtomic(file, data)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function save() {
|
|
86
|
+
writeJsonAtomic(file, data)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function list() {
|
|
90
|
+
return data.entries.slice()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function find(id) {
|
|
94
|
+
return data.entries.find((item) => item.id === id)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function add(input = {}) {
|
|
98
|
+
const text = normalizeText(input.text)
|
|
99
|
+
if (!text) throw new Error('梗的內容不能空白')
|
|
100
|
+
if (text.length > MAX_TEXT) throw new Error(`梗的內容最多 ${MAX_TEXT} 字`)
|
|
101
|
+
const entry = {
|
|
102
|
+
id: 'j_' + randomBytes(4).toString('hex'),
|
|
103
|
+
text,
|
|
104
|
+
usage: normalizeText(input.usage).slice(0, MAX_USAGE),
|
|
105
|
+
example: normalizeText(input.example).slice(0, MAX_USAGE),
|
|
106
|
+
taboo: normalizeText(input.taboo).slice(0, MAX_USAGE),
|
|
107
|
+
mood: normalizeText(input.mood).slice(0, 20),
|
|
108
|
+
source: normalizeText(input.source).slice(0, 20) || 'manual',
|
|
109
|
+
enabled: input.enabled !== false,
|
|
110
|
+
createdAt: nowIso(),
|
|
111
|
+
}
|
|
112
|
+
data.entries.push(entry)
|
|
113
|
+
if (data.entries.length > MAX_ENTRIES) data.entries = data.entries.slice(-MAX_ENTRIES)
|
|
114
|
+
save()
|
|
115
|
+
return entry
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function update(id, patch = {}) {
|
|
119
|
+
const entry = find(id)
|
|
120
|
+
if (!entry) throw new Error('找不到這則梗')
|
|
121
|
+
for (const key of ['text', 'usage', 'example', 'taboo', 'mood']) {
|
|
122
|
+
if (patch[key] !== undefined) entry[key] = normalizeText(patch[key]).slice(0, key === 'text' ? MAX_TEXT : MAX_USAGE)
|
|
123
|
+
}
|
|
124
|
+
if (patch.enabled !== undefined) entry.enabled = Boolean(patch.enabled)
|
|
125
|
+
save()
|
|
126
|
+
return entry
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function remove(id) {
|
|
130
|
+
const index = data.entries.findIndex((item) => item.id === id)
|
|
131
|
+
if (index < 0) throw new Error('找不到這則梗')
|
|
132
|
+
const [removed] = data.entries.splice(index, 1)
|
|
133
|
+
save()
|
|
134
|
+
return removed
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function removeBySource(source) {
|
|
138
|
+
const before = data.entries.length
|
|
139
|
+
data.entries = data.entries.filter((item) => item.source !== source)
|
|
140
|
+
if (data.entries.length !== before) save()
|
|
141
|
+
return before - data.entries.length
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function importMany(source, items, options = {}) {
|
|
145
|
+
const enabled = options.enabled !== false
|
|
146
|
+
const seen = new Set(data.entries.map((item) => `${item.source}:${item.text}`))
|
|
147
|
+
let added = 0
|
|
148
|
+
for (const item of items) {
|
|
149
|
+
const text = normalizeText(item.text)
|
|
150
|
+
if (!text || text.length < 3 || text.length > MAX_TEXT) continue
|
|
151
|
+
const key = `${source}:${text}`
|
|
152
|
+
if (seen.has(key)) continue
|
|
153
|
+
seen.add(key)
|
|
154
|
+
data.entries.push({
|
|
155
|
+
id: 'j_' + randomBytes(4).toString('hex'),
|
|
156
|
+
text,
|
|
157
|
+
usage: normalizeText(item.usage).slice(0, MAX_USAGE),
|
|
158
|
+
mood: normalizeText(item.mood).slice(0, 20),
|
|
159
|
+
source,
|
|
160
|
+
enabled,
|
|
161
|
+
createdAt: nowIso(),
|
|
162
|
+
})
|
|
163
|
+
added += 1
|
|
164
|
+
if (data.entries.length >= MAX_ENTRIES) break
|
|
165
|
+
}
|
|
166
|
+
save()
|
|
167
|
+
return added
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function contextText(getConfig) {
|
|
171
|
+
const cfg = getConfig() || {}
|
|
172
|
+
const jokes = cfg.jokes || {}
|
|
173
|
+
if (jokes.enabled === false) return ''
|
|
174
|
+
const allowTieba = jokes.tieba !== false
|
|
175
|
+
const allowWanjiqi = jokes.wanjiqi === true
|
|
176
|
+
const usable = data.entries.filter((item) => {
|
|
177
|
+
if (!item.enabled) return false
|
|
178
|
+
if (item.source === 'tieba' && !allowTieba) return false
|
|
179
|
+
if (item.source === 'wanjiqi' && !allowWanjiqi) return false
|
|
180
|
+
return true
|
|
181
|
+
})
|
|
182
|
+
if (usable.length === 0) return ''
|
|
183
|
+
const count = Math.max(1, Math.min(8, Number(jokes.perTurn) || 3))
|
|
184
|
+
const maxUse = Math.max(1, Math.min(3, Number(jokes.maxUsePerReply) || 1))
|
|
185
|
+
const picked = []
|
|
186
|
+
const pool = usable.slice()
|
|
187
|
+
while (picked.length < count && pool.length > 0) {
|
|
188
|
+
const index = Math.floor(Math.random() * pool.length)
|
|
189
|
+
picked.push(pool.splice(index, 1)[0])
|
|
190
|
+
}
|
|
191
|
+
const lines = picked.map((item) => `- ${item.text}${item.usage ? `(${item.usage})` : ''}`)
|
|
192
|
+
return [
|
|
193
|
+
`【玩梗】你可以自然用一點梗,但一次回覆最多用 ${maxUse} 個,不要解釋梗,也不要每句都塞;正經、難過、第一次聊天時不要用。`,
|
|
194
|
+
'可用(挑順眼的,用不上就別用):',
|
|
195
|
+
...lines,
|
|
196
|
+
'- 不要用貶低、地域、性別、疾病或政治相關的梗;不確定就不要用。',
|
|
197
|
+
].join('\n')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function stats() {
|
|
201
|
+
const bySource = {}
|
|
202
|
+
let enabled = 0
|
|
203
|
+
for (const item of data.entries) {
|
|
204
|
+
bySource[item.source] = (bySource[item.source] || 0) + 1
|
|
205
|
+
if (item.enabled) enabled += 1
|
|
206
|
+
}
|
|
207
|
+
return { file, total: data.entries.length, enabled, bySource }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { file, list, find, add, update, remove, removeBySource, importMany, contextText, stats }
|
|
211
|
+
}
|
package/lib/tieba-sync.mjs
CHANGED
|
@@ -128,7 +128,70 @@ export async function reloadPersonaPlugin(ctx) {
|
|
|
128
128
|
return { ok: true, uidBefore, uidAfter: entry.fiber?.uid ?? null }
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
export function
|
|
131
|
+
export function personaOverlayPath(personaId) {
|
|
132
|
+
const safe = String(personaId || '').replace(/[^A-Za-z0-9:_-]/g, '_')
|
|
133
|
+
return path.join(dataDir(), 'persona-overlays', `${safe}.md`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function readPersonaOverlay(personaId) {
|
|
137
|
+
try {
|
|
138
|
+
return fs.readFileSync(personaOverlayPath(personaId), 'utf8')
|
|
139
|
+
} catch {
|
|
140
|
+
return ''
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 從刷貼吧的風格庫挑短句,當作「梗/金句」匯入。 */
|
|
145
|
+
export function readTiebaStyleSamples({ limit = 40, minLikes = 30 } = {}) {
|
|
146
|
+
const file = path.join(dshHome(), 'tieba-duty', 'style-lib.json')
|
|
147
|
+
const raw = readJsonSafe(file, null)
|
|
148
|
+
if (!raw || typeof raw !== 'object') return []
|
|
149
|
+
const out = []
|
|
150
|
+
const seen = new Set()
|
|
151
|
+
for (const [bar, pack] of Object.entries(raw)) {
|
|
152
|
+
let barLabel = bar
|
|
153
|
+
try { barLabel = decodeURIComponent(bar) } catch { /* keep raw */ }
|
|
154
|
+
if (!/弱智|抽象|搞笑|梗|段子|地狱|孫|孙|乐子|整活/.test(barLabel)) continue
|
|
155
|
+
const samples = Array.isArray(pack?.samples) ? pack.samples : []
|
|
156
|
+
for (const sample of samples) {
|
|
157
|
+
const text = String(sample?.text || '').replace(/\s+/g, ' ').trim()
|
|
158
|
+
const likes = Number(sample?.likes) || 0
|
|
159
|
+
const cjk = (text.match(/[\u4e00-\u9fff]/g) || []).length
|
|
160
|
+
const ascii = (text.match(/[A-Za-z]/g) || []).length
|
|
161
|
+
if (likes < Math.max(50, minLikes) || text.length < 5 || text.length > 30) continue
|
|
162
|
+
if (cjk < 3 || ascii > 2) continue
|
|
163
|
+
if (/^(?:\[|https?:|image_emoticon)/i.test(text)) continue
|
|
164
|
+
if (seen.has(text)) continue
|
|
165
|
+
seen.add(text)
|
|
166
|
+
let barName = bar
|
|
167
|
+
try { barName = decodeURIComponent(bar) } catch { /* keep raw */ }
|
|
168
|
+
out.push({ text, usage: `貼吧「${barName}」高讚回覆(${likes} 讚)` })
|
|
169
|
+
if (out.length >= limit) return out
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return out
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** 把梗類貼吧加進刷貼吧的 bars / barPool(會先備份設定檔)。 */
|
|
176
|
+
export function ensureTiebaBars(bars = ['弱智吧', '抽象吧', '搞笑吧', '梗吧']) {
|
|
177
|
+
const file = path.join(dshHome(), 'tieba-duty', 'config.json')
|
|
178
|
+
const raw = readJsonSafe(file, null)
|
|
179
|
+
if (!raw) throw new Error(`讀不到刷貼吧設定:${file}`)
|
|
180
|
+
const currentBars = Array.isArray(raw.bars) ? raw.bars.slice() : []
|
|
181
|
+
const currentPool = Array.isArray(raw.barPool) ? raw.barPool.slice() : currentBars.slice()
|
|
182
|
+
const added = []
|
|
183
|
+
for (const bar of bars) {
|
|
184
|
+
if (!currentBars.includes(bar)) { currentBars.push(bar); added.push(bar) }
|
|
185
|
+
if (!currentPool.includes(bar)) currentPool.push(bar)
|
|
186
|
+
}
|
|
187
|
+
if (added.length > 0) {
|
|
188
|
+
fs.copyFileSync(file, `${file}.bak-linebot-${Date.now()}`)
|
|
189
|
+
writeJsonAtomic(file, { ...raw, bars: currentBars, barPool: currentPool })
|
|
190
|
+
}
|
|
191
|
+
return { added, bars: currentBars, file }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function createTiebaSync({ ctx, store, logger, getCatalog }) {
|
|
132
195
|
const dir = dataDir()
|
|
133
196
|
const backupDir = path.join(dir, 'persona-backups')
|
|
134
197
|
let timer = null
|
|
@@ -139,17 +202,53 @@ export function createTiebaSync({ ctx, store, logger }) {
|
|
|
139
202
|
return cfg.sync || {}
|
|
140
203
|
}
|
|
141
204
|
|
|
142
|
-
function
|
|
205
|
+
async function catalog() {
|
|
206
|
+
try {
|
|
207
|
+
return (await getCatalog?.()) || []
|
|
208
|
+
} catch {
|
|
209
|
+
return []
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function overlayWrite(personaId, text) {
|
|
214
|
+
const file = personaOverlayPath(personaId)
|
|
215
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
216
|
+
fs.writeFileSync(file, text)
|
|
217
|
+
return file
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function preview() {
|
|
143
221
|
const tieba = readTiebaPersona()
|
|
222
|
+
const targetId = String(config().targetPersonaId || '').trim() || 'dafeiyu'
|
|
223
|
+
const list = await catalog()
|
|
224
|
+
const info = list.find((item) => item.id === targetId)
|
|
225
|
+
const builtin = Boolean(info?.builtin) || ['dafeiyu', 'acha', 'tinglan'].includes(targetId)
|
|
226
|
+
const overlayMode = builtin || String(targetId).startsWith('local:') || info?.source === 'local'
|
|
227
|
+
const section = buildSection(tieba)
|
|
228
|
+
if (overlayMode) {
|
|
229
|
+
const oldText = readPersonaOverlay(targetId)
|
|
230
|
+
return {
|
|
231
|
+
ok: true,
|
|
232
|
+
mode: 'overlay',
|
|
233
|
+
targetId,
|
|
234
|
+
targetName: info?.name || targetId,
|
|
235
|
+
version: tieba.version,
|
|
236
|
+
samples: tieba.samples,
|
|
237
|
+
traits: tieba.traits,
|
|
238
|
+
currentSection: oldText.trim(),
|
|
239
|
+
nextSection: section,
|
|
240
|
+
changed: sansStamp(oldText) !== sansStamp(section),
|
|
241
|
+
overlayPath: personaOverlayPath(targetId),
|
|
242
|
+
}
|
|
243
|
+
}
|
|
144
244
|
const { raw } = readPersonaStore()
|
|
145
|
-
const targetId = String(config().targetPersonaId || '').trim()
|
|
146
245
|
const target = raw.personas.find((item) => item.id === targetId)
|
|
147
246
|
if (!target) throw new Error(`大肥魚人設裡找不到「${targetId || '(未設定)'}」`)
|
|
148
|
-
const section = buildSection(tieba)
|
|
149
247
|
const oldText = String(target.text || '')
|
|
150
248
|
const nextText = applySection(oldText, section)
|
|
151
249
|
return {
|
|
152
250
|
ok: true,
|
|
251
|
+
mode: 'persona',
|
|
153
252
|
targetId,
|
|
154
253
|
targetName: target.name,
|
|
155
254
|
version: tieba.version,
|
|
@@ -167,48 +266,69 @@ export function createTiebaSync({ ctx, store, logger }) {
|
|
|
167
266
|
const cfg = config()
|
|
168
267
|
if (!force && !cfg.enabled) return { skipped: 'disabled' }
|
|
169
268
|
const tieba = readTiebaPersona()
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
: '尚未選擇要同步的人設(面板:回覆人設 → 貼吧學習同步)')
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const oldText = String(target.text || '')
|
|
180
|
-
let nextText = applySection(oldText, buildSection(tieba))
|
|
181
|
-
if (cfg.syncTraits !== false) nextText = applyTraits(nextText, tieba.traits)
|
|
182
|
-
const changed = sansStamp(nextText) !== sansStamp(oldText)
|
|
183
|
-
const stateSync = store.getState().sync || {}
|
|
184
|
-
if (!changed && stateSync.version === tieba.version) {
|
|
185
|
-
return { skipped: 'unchanged', version: tieba.version, targetId }
|
|
186
|
-
}
|
|
187
|
-
|
|
269
|
+
const targetId = String(cfg.targetPersonaId || '').trim() || 'dafeiyu'
|
|
270
|
+
const list = await catalog()
|
|
271
|
+
const info = list.find((item) => item.id === targetId)
|
|
272
|
+
const builtin = Boolean(info?.builtin) || ['dafeiyu', 'acha', 'tinglan'].includes(targetId)
|
|
273
|
+
const overlayMode = builtin || String(targetId).startsWith('local:') || info?.source === 'local'
|
|
274
|
+
const section = buildSection(tieba)
|
|
188
275
|
const at = Date.now()
|
|
276
|
+
const stateSync = store.getState().sync || {}
|
|
277
|
+
let changed = false
|
|
189
278
|
let backupPath = ''
|
|
190
279
|
let reload = null
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
280
|
+
let sectionText = section
|
|
281
|
+
|
|
282
|
+
if (overlayMode) {
|
|
283
|
+
const oldText = readPersonaOverlay(targetId)
|
|
284
|
+
const nextText = applySection(oldText, section).trimEnd() + '\n'
|
|
285
|
+
changed = sansStamp(oldText) !== sansStamp(section)
|
|
286
|
+
if (!changed && stateSync.version === tieba.version) {
|
|
287
|
+
return { skipped: 'unchanged', version: tieba.version, targetId, mode: 'overlay' }
|
|
288
|
+
}
|
|
289
|
+
if (changed) {
|
|
290
|
+
fs.mkdirSync(backupDir, { recursive: true })
|
|
291
|
+
if (oldText) {
|
|
292
|
+
backupPath = path.join(backupDir, `overlay-${targetId}-${new Date().toISOString().replace(/[:.]/g, '-')}.md`)
|
|
293
|
+
fs.writeFileSync(backupPath, oldText)
|
|
294
|
+
}
|
|
295
|
+
overlayWrite(targetId, nextText)
|
|
296
|
+
logger.info(`已把刷貼吧學到的口氣寫進「${info?.name || targetId}」的覆蓋層`)
|
|
297
|
+
}
|
|
298
|
+
sectionText = nextText
|
|
299
|
+
} else {
|
|
300
|
+
const { raw } = readPersonaStore()
|
|
301
|
+
const target = raw.personas.find((item) => item.id === targetId)
|
|
302
|
+
if (!target) throw new Error(`大肥魚人設裡找不到「${targetId}」`)
|
|
303
|
+
const oldText = String(target.text || '')
|
|
304
|
+
let nextText = applySection(oldText, section)
|
|
305
|
+
if (cfg.syncTraits !== false) nextText = applyTraits(nextText, tieba.traits)
|
|
306
|
+
changed = sansStamp(nextText) !== sansStamp(oldText)
|
|
307
|
+
if (!changed && stateSync.version === tieba.version) {
|
|
308
|
+
return { skipped: 'unchanged', version: tieba.version, targetId, mode: 'persona' }
|
|
309
|
+
}
|
|
310
|
+
if (changed) {
|
|
311
|
+
if (nextText.length > 64000) throw new Error(`人設正文會超過 64000 字(${nextText.length})`)
|
|
312
|
+
fs.mkdirSync(backupDir, { recursive: true })
|
|
313
|
+
backupPath = path.join(backupDir, `personas-${new Date().toISOString().replace(/[:.]/g, '-')}.json`)
|
|
314
|
+
fs.writeFileSync(backupPath, JSON.stringify(raw, null, 2))
|
|
315
|
+
const next = {
|
|
316
|
+
...raw,
|
|
317
|
+
personas: raw.personas.map((item) => (item.id === targetId ? { ...item, text: nextText } : item)),
|
|
318
|
+
}
|
|
319
|
+
writeJsonAtomic(dafeiyuStorePath(), next)
|
|
320
|
+
logger.info(`已把刷貼吧學到的語氣寫回人設「${target.name || targetId}」(版本 ${tieba.version})`)
|
|
200
321
|
}
|
|
201
|
-
|
|
202
|
-
logger.info(`已把刷貼吧學到的語氣寫回人設「${target.name || targetId}」(版本 ${tieba.version})`)
|
|
322
|
+
sectionText = nextText
|
|
203
323
|
}
|
|
204
324
|
|
|
205
|
-
// 先記帳再重載:重載要幾百毫秒,中途若插件重啟,新實例讀到的 state 才不會還停在舊的 at 而立刻再跑一次。
|
|
206
325
|
store.updateState((state) => ({
|
|
207
326
|
...state,
|
|
208
327
|
sync: {
|
|
209
328
|
at,
|
|
210
329
|
version: tieba.version,
|
|
211
330
|
targetId,
|
|
331
|
+
mode: overlayMode ? 'overlay' : 'persona',
|
|
212
332
|
samples: tieba.samples.length,
|
|
213
333
|
changed,
|
|
214
334
|
backupPath,
|
|
@@ -217,7 +337,7 @@ export function createTiebaSync({ ctx, store, logger }) {
|
|
|
217
337
|
},
|
|
218
338
|
}))
|
|
219
339
|
|
|
220
|
-
if (changed && cfg.reloadPersona !== false) {
|
|
340
|
+
if (!overlayMode && changed && cfg.reloadPersona !== false) {
|
|
221
341
|
try {
|
|
222
342
|
reload = await reloadPersonaPlugin(ctx)
|
|
223
343
|
if (!reload.ok) logger.warn(`重載大肥魚外掛失敗:${reload.error}`)
|
|
@@ -231,11 +351,13 @@ export function createTiebaSync({ ctx, store, logger }) {
|
|
|
231
351
|
at,
|
|
232
352
|
version: tieba.version,
|
|
233
353
|
targetId,
|
|
234
|
-
targetName:
|
|
354
|
+
targetName: info?.name || targetId,
|
|
355
|
+
mode: overlayMode ? 'overlay' : 'persona',
|
|
235
356
|
changed,
|
|
236
357
|
samples: tieba.samples.length,
|
|
237
358
|
backupPath,
|
|
238
359
|
reload,
|
|
360
|
+
nextSection: sectionText,
|
|
239
361
|
}
|
|
240
362
|
})()
|
|
241
363
|
try {
|
|
@@ -259,7 +381,7 @@ export function createTiebaSync({ ctx, store, logger }) {
|
|
|
259
381
|
const intervalMs = Math.max(5, Number(cfg.intervalMinutes) || 60) * 60000
|
|
260
382
|
return {
|
|
261
383
|
enabled: Boolean(cfg.enabled),
|
|
262
|
-
targetPersonaId: cfg.targetPersonaId || '',
|
|
384
|
+
targetPersonaId: cfg.targetPersonaId || 'dafeiyu',
|
|
263
385
|
intervalMinutes: Number(cfg.intervalMinutes) || 60,
|
|
264
386
|
syncTraits: cfg.syncTraits !== false,
|
|
265
387
|
reloadPersona: cfg.reloadPersona !== false,
|
|
@@ -271,6 +393,7 @@ export function createTiebaSync({ ctx, store, logger }) {
|
|
|
271
393
|
lastBackupPath: stateSync.backupPath || '',
|
|
272
394
|
reloadOk: stateSync.reloadOk ?? null,
|
|
273
395
|
lastTargetId: stateSync.targetId || '',
|
|
396
|
+
lastMode: stateSync.mode || '',
|
|
274
397
|
nextAt: cfg.enabled ? (lastAt ? lastAt + intervalMs : Date.now()) : 0,
|
|
275
398
|
tiebaVersion,
|
|
276
399
|
tiebaSamples,
|