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 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 the tone samples learned while browsing Tieba back into the persona's "Tieba tone" section, preview diff, sync now |
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 personas only; off by default, pick one first), the check interval (default 60 minutes), whether to sync personality numbers, and whether to reload the dafeiyu plugin after writing. This feature requires the dafeiyu plugin.
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
- - パネルの「返信ペルソナ → 貼吧学習の同期」で、オン/オフ、同期先ペルソナ(大肥魚のペルソナのみ。既定はオフなので先に 1 つ選んでください)、確認間隔(既定 60 分)、性格値も同期するか、書き込み後に大肥魚プラグインを再読み込みするかを設定できます。この機能には大肥魚プラグインが必要です。
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
- - 패널의 "답장 페르소나 → 貼吧 학습 동기화"에서 켜기/끄기, 동기화 대상 페르소나(大肥魚 페르소나만; 기본은 꺼져 있으니 먼저 하나 고르세요), 확인 간격(기본 60분), 성격 수치 동기화 여부, 기록 후 大肥魚 플러그인 리로드 여부를 설정할 수 있습니다. 이 기능에는 大肥魚 플러그인이 필요합니다.
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
- - 面板「回覆人設 → 貼吧學習同步」可設定開關、同步目標人設(只能選大肥魚的人設;預設關閉,請先選一個)、檢查間隔(預設 60 分鐘)、是否同步性格數值、寫入後是否重載大肥魚外掛。這項功能需要安裝大肥魚外掛。
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
- - 面板「回复人设 → 贴吧学习同步」可设置开关、同步目标人设(只能选大肥鱼的人设;默认关闭,请先选一个)、检查间隔(默认 60 分钟)、是否同步性格数值、写入后是否重载大肥鱼插件。这项功能需要安装大肥鱼插件。
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 = personaOptions.filter((item) => !item.builtin)
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: 8,
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: 7,
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: 9, title: '主動傳訊(大肥魚自己找人聊天)', desc: '由插件內建排程器執行:每隔幾分鐘擲骰子,符合條件就請 Agent 主動傳一句;不適合時 Agent 可回 SKIP。' },
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: 10, title: '表情包(dsh-meme 圖庫)', desc: '角色可以在文字後面配一張圖,也可以只回一張圖。圖片來自 dsh-meme 外掛的圖庫;要新增、分類或改描述,請到 dsh-meme 的設定頁。' },
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: 11, title: '使用者', desc: '與 bot 互動過的 LINE 使用者。每個人有自己的 DSH session 與工作資料夾(記憶、檔案不互通),人設也可以個別指定。' },
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: 12, title: '群組', desc: '被加進群組時,只有被 @、被引用,或依機率擲骰才回覆;每個群組有自己的 session、工作資料夾、人設與記憶;比較晚加入時可以匯入 LINE 匯出的聊天記錄補記憶。' },
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 就只在被 @ 或引用時回' },
@@ -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.8.3'
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
- summary.catalog = [...local]
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
+ }
@@ -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 createTiebaSync({ ctx, store, logger }) {
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 preview() {
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 { raw } = readPersonaStore()
171
- const targetId = String(cfg.targetPersonaId || '').trim()
172
- const target = raw.personas.find((item) => item.id === targetId)
173
- if (!target) {
174
- throw new Error(targetId
175
- ? `大肥魚人設裡找不到「${targetId}」`
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
- if (changed) {
192
- if (nextText.length > 64000) throw new Error(`人設正文會超過 64000 字(${nextText.length})`)
193
- fs.mkdirSync(backupDir, { recursive: true })
194
- backupPath = path.join(backupDir, `personas-${new Date().toISOString().replace(/[:.]/g, '-')}.json`)
195
- fs.writeFileSync(backupPath, JSON.stringify(raw, null, 2))
196
- // 只換目標人設的 text,其他欄位(mode/current/disabled/workspaces)原封不動。
197
- const next = {
198
- ...raw,
199
- personas: raw.personas.map((item) => (item.id === targetId ? { ...item, text: nextText } : item)),
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
- writeJsonAtomic(dafeiyuStorePath(), next)
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: target.name,
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-line-bot",
3
- "version": "0.8.3",
3
+ "version": "0.9.1",
4
4
  "description": "把 LINE 官方帳號(Messaging API)接進 DeepSeek Harness 的插件:內建 Cloudflare Tunnel、人設選擇(大肥魚或插件自訂)、主動傳訊與完整設定面板。",
5
5
  "type": "module",
6
6
  "main": "index.js",