dsh-line-bot 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -8,7 +8,7 @@
8
8
  node bin/cli.mjs doctor --json # 或 npx dsh-line-bot doctor --json
9
9
  ```
10
10
 
11
- - 伺服器在跑時,doctor 會打本機 `http://127.0.0.1:8791/doctor`,檢查:伺服器、Cloudflare Tunnel、公開網址健康、LINE webhook、dsh-meme 圖庫、表情包設定、轉圖工具、模型、圖片可送達測試、最近錯誤。
11
+ - 伺服器在跑時,doctor 會打本機 `http://127.0.0.1:8791/doctor`,檢查:伺服器、Cloudflare Tunnel、公開網址健康、LINE webhook、dsh-meme 圖庫、表情包設定、轉圖工具、模型、modlens 看圖能力、圖片可送達測試、最近錯誤。
12
12
  - 伺服器沒跑時會退成離線檢查(設定檔、憑證、dsh-meme、轉圖工具、日誌)。
13
13
  - `--fix` 會做安全修復:重啟 tunnel、重設 webhook、重新讀 bot 資訊;不會改設定、不會刪資料。
14
14
 
@@ -25,8 +25,8 @@ curl -s http://127.0.0.1:8791/health
25
25
  | --- | --- | --- | --- |
26
26
  | 完全沒回覆 | `server`、`tunnel`、`webhook` | webhook 指到舊 tunnel;LINE「Use webhook」沒開;伺服器沒啟動 | `doctor --fix`;面板「套用 Webhook」;到 LINE console 開 Use webhook |
27
27
  | 有回文字但沒圖片 | `dsh-meme`、`meme-config`、`public-url`、`media`、`converters` | 沒裝 dsh-meme;`stickers.enabled=false`;公開網址還沒就緒;GIF/webp 沒有轉圖工具 | 面板「表情包」開啟並看圖庫;`doctor --fix`;安裝 ffmpeg |
28
- | 回覆出現 `[[meme:…]]`、`<meme#18>` 文字 | 版本 | 舊版不認得標記格式 | 升級到 0.6.1+(目前 0.8.x) |
29
- | 貼圖看不懂 | `model` | 目前模型不吃圖(`inputModalities` 沒有 image) | 面板「模型與接口來源」換成有 `text+image` 的模型 |
28
+ | 回覆出現 `[[meme:…]]`、`<meme#18>` 文字 | 版本 | 舊版不認得標記格式 | 升級到 0.6.1+(目前 0.9.x) |
29
+ | 貼圖/圖片看不懂 | `model`、`vision` | 模型不吃圖,或 modlens 不可用/失敗 | 面板「模型與接口來源」換成有 `text+image` 的模型;或安裝 modlens 並設定 provider |
30
30
  | 群組不回 | 面板「群組」 | 沒被 @/引用、擲骰沒中、群組功能關閉 | 看面板回覆機率與開關 |
31
31
  | 主動訊息沒出現 | 面板「主動傳訊」 | 機率、每日/每月上限、靜音時段、冷卻、沒有 session 的使用者 | 看 `status.nudge` 與日誌 |
32
32
  | 餘額提醒沒出現 | `balance` | provider 不是 `deepseek-account`、餘額高於門檻、冷卻中、靜音 | 面板「DeepSeek 餘額」看 `applicable`、`lastAskAt` |
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.2
4
+
5
+ - LINE 一般圖片:收到 `image` 訊息時透過 LINE content API 下載,模型支援圖片就直接附圖;不支援時改用 modlens 或另一個視覺模型描述。
6
+ - 貼圖:模型不吃圖時,先用 modlens 看圖,失敗再改用支援圖片的模型描述,最後才退回貼圖關鍵字。
7
+ - 面板「LINE 憑證」新增「看得懂一般圖片」開關與目前看圖方式說明。
8
+ - 診斷(doctor)新增「看圖能力(modlens)」檢查。
9
+
10
+ ## 0.9.1
11
+ - 貼吧學習同步的目標下拉現在也包含「插件自訂人設」(`local:`);選自訂人設時會寫入 `<DSH_HOME>/line-bot/persona-overlays/<id>.md` 覆蓋層,每輪注入。
12
+ - 讀不到大肥魚 HTTP bridge 時,改成直接讀取 `<DSH_HOME>/storages/dsh-persona-dafeiyu/personas.json` 並列出內建人設,不再讓同步目標變空。
13
+ - 修正本機自訂人設同步完成後回報的模式(應為 overlay)。
14
+ - 貼吧梗只從梗類貼吧匯入(一般回覆碎片不再被當成梗);內建種子擴充到 24 則。
15
+ - 明確要求角色在主人說「不要圖」時不要加表情包標記。
16
+
3
17
  ## 0.9.0
4
18
  - 玩梗:新增文字梗庫(內建種子、貼吧高讚金句、wanjiqi-meme 彈幕梗),面板可個別開關來源、調整每輪注入數量、新增/刪除梗;每輪只餵幾條給角色,並限制一次回覆最多用一個。
5
19
  - 貼吧學習可寫入內建人設:大肥魚、阿茶、听澜等內建人設不能改原文,改成寫入 `<DSH_HOME>/line-bot/persona-overlays/<id>.md` 覆蓋層,每輪注入、不用重載;自訂人設仍直接改本文。
package/README.en.md CHANGED
@@ -98,7 +98,7 @@ At the top of the panel is a **sticky save bar**: "Save changes / Refresh" stays
98
98
  | Jokes | Text joke library: built-in seeds, Tieba quotes, wanjiqi barrage memes; per-source toggles, per-turn injection count, add/delete entries |
99
99
  | Personal memory | Per-user long-term memory, expiry cleanup, daily self-review, manual delete |
100
100
  | Memes | Reply with images/stickers from the dsh-meme library, enable/disable, image limit, and sample previews |
101
- | Sticker vision | Fetch incoming LINE stickers and show them to the model (can be disabled) |
101
+ | Sticker & image vision | Fetch incoming LINE stickers or images and show them to the model; when the model cannot read images, describe them with modlens or a vision model (can be disabled) |
102
102
  | DeepSeek balance | Current balance, per-turn context, low-balance proactive reminder, threshold and cooldown (deepseek-account only) |
103
103
  | Proactive messages | Toggle, probability, interval, daily/monthly caps, minimum gap, quiet hours, idle, target users, prompt, send a test now, statistics |
104
104
  | Users | The DSH session each LINE user maps to, their own workspace folder, their current persona (editable), message count, last interaction, clear memory / reset session |
@@ -220,7 +220,8 @@ The persona can attach a sticker to a text reply, or reply with just an image. I
220
220
  - Each turn injects a numbered sample of the library into context; when the persona wants a picture, it appends `[[meme:#N]]` (or a caption) at the end of its reply, and the plugin turns that into a LINE image message.
221
221
  - Images are served to LINE through the current Cloudflare public URL (`<url>/media/<token>`), and the URL follows quick-tunnel restarts automatically.
222
222
  - The panel's "Memes" card can disable the feature, limit images per reply (1–3), and preview library samples. To add or re-tag images, use dsh-meme's own settings.
223
- - When a LINE sticker arrives, the bot fetches the sticker image and shows it to the model (vision-capable models read the image; otherwise the bot falls back to the keywords LINE provides), so the persona can react to the mood. Turn "Understand stickers" off in the LINE credentials card to use keywords only.
223
+ - When a LINE sticker arrives, the bot fetches the image and shows it to the model; vision-capable models read it directly, otherwise the bot asks modlens, then tries another vision model, and only then falls back to the keywords LINE provides. Turn "Understand stickers" off in the LINE credentials card to use keywords only.
224
+ - Regular images work the same way: the bot downloads them through LINE's content API and attaches them to the message; if the model cannot read images, the same modlens / vision-model description path is used. Turn "Understand images" off in the LINE credentials card to skip them.
224
225
  - Without dsh-meme installed, the feature stays off and replies are text only.
225
226
  ## Diagnostics
226
227
  - Panel → Advanced → Run diagnostics checks the server, tunnel, public URL, webhook, dsh-meme library, image converters, model, and recent errors; Safe repair only reconnects the tunnel and re-applies the webhook.
package/README.ja.md CHANGED
@@ -98,7 +98,7 @@ npx dsh-line-bot install --no-companions
98
98
  | ジョーク | テキストネタ集:内蔵シード、貼吧名台詞、wanjiqi 弾幕ネタ。ソース別の切り替え、ターンごとの注入数、追加/削除 |
99
99
  | 個人メモリ | ユーザーごとの長期記憶、期限切れの自動削除、毎日の自己整理、手動削除 |
100
100
  | 表情パック | dsh-meme の画像ライブラリで画像/スタンプを返信、オンオフ、1 回の上限、サンプル表示 |
101
- | スタンプ理解 | LINE スタンプを受信したら画像を取得してモデルに見せる(オフ可) |
101
+ | スタンプ・画像理解 | LINE スタンプや通常の画像を受信したら取得してモデルに見せる。画像非対応モデルは modlens や別の視覚モデルで説明(オフ可) |
102
102
  | DeepSeek 残高 | 現在の残高、毎ターンの注入、残高が少ないときの主動リマインド、しきい値とクールダウン(deepseek-account のみ) |
103
103
  | 自発メッセージ | オン/オフ、確率、間隔、1 日/1 か月の上限、最短間隔、静かな時間帯、idle、対象ユーザー、プロンプト、今すぐ試送、統計 |
104
104
  | ユーザー | LINE ユーザーごとに対応する DSH セッション、専用ワークスペース、現在のペルソナ(直接変更可)、メッセージ数、最終やりとり、メモリ消去/セッション再作成 |
@@ -220,7 +220,8 @@ Bot がグループ(または複数人のルーム)に追加されたとき
220
220
  - 毎ターン、ライブラリのサンプル(番号+説明)を context に注入します。画像を添えたいときは返信の最後に `[[meme:#番号]]`(または説明)を付けます。プラグインが LINE の image message に変換します。
221
221
  - 画像は現在の Cloudflare 公開 URL(`<URL>/media/<token>`)経由で LINE に渡されます。quick tunnel の再起動で URL が変わっても自動で追従します。
222
222
  - パネルの「表情パック」カードでオンオフ、1 回あたりの上限(1〜3)、ライブラリのサンプル表示ができます。画像の追加や分類の変更は dsh-meme 側の設定で行います。
223
- - LINE スタンプを受信すると、スタンプ画像を取得してモデルに見せます(画像対応モデルはそのまま読み、非対応なら LINE のキーワードを使います)。「LINE 認証情報」カードの「スタンプを理解する」でオフにできます。
223
+ - LINE スタンプを受信すると、スタンプ画像を取得してモデルに見せます。画像対応モデルはそのまま読み、非対応ならまず modlens、次に別の視覚モデルで説明し、最後に LINE のキーワードを使います。「LINE 認証情報」カードの「スタンプを理解する」でオフにできます。
224
+ - 通常の画像も同じです。LINE content API でダウンロードしてモデルに添付し、画像非対応なら同じ modlens/視覚モデルの説明経路を使います。「LINE 認証情報」カードの「画像を理解する」でオフにできます。
224
225
  - dsh-meme がインストールされていない場合、この機能は無効になりテキストのみ返します。
225
226
  ## 診断
226
227
  - パネルの「詳細設定 → 診断を実行」でサーバー、Tunnel、公開 URL、webhook、dsh-meme 図庫、画像変換ツール、モデル、最近のエラーを確認できます。「安全な修復」は tunnel 再接続と webhook 再設定だけを行います。
package/README.ko.md CHANGED
@@ -98,7 +98,7 @@ npx dsh-line-bot install --no-companions
98
98
  | 말장난 | 텍스트 밈: 내장 시드, 貼吧 명언, wanjiqi 탄막 밈. 출처별 켜기/끄기, 턴당 주입 수, 추가/삭제 |
99
99
  | 개인 기억 | 사용자별 장기 기억, 만료 자동 삭제, 매일 자기 정리, 수동 삭제 |
100
100
  | 이모티콘 | dsh-meme 라이브러리로 이미지/스티커 답장, 켜기/끄기, 1회 최대 장수, 샘플 미리보기 |
101
- | 스티커 이해 | LINE 스티커를 받으면 이미지를 가져와 모델에 보여 줌 (끌 수 있음) |
101
+ | 스티커·이미지 이해 | LINE 스티커나 일반 이미지를 받으면 가져와 모델에 보여 줌. 이미지 미지원 모델은 modlens 또는 다른 비전 모델로 설명 (끌 수 있음) |
102
102
  | DeepSeek 잔액 | 현재 잔액, 매 턴 주입, 잔액이 적을 때 캐릭터가 먼저 알림, 기준 금액과 쿨다운(deepseek-account 전용) |
103
103
  | 능동 메시지 | 켜기/끄기, 확률, 간격, 일일/월간 상한, 최소 간격, 조용한 시간대, idle, 대상 사용자, 프롬프트, 지금 시험 발송, 통계 |
104
104
  | 사용자 | LINE 사용자별 DSH 세션, 전용 작업 폴더, 현재 페르소나(직접 변경 가능), 메시지 수, 마지막 상호작용, 기억 지우기/세션 재설정 |
@@ -218,7 +218,8 @@ Bot이 그룹(또는 다인 룸)에 추가되면:
218
218
  - 이미지는 현재 Cloudflare 공개 URL(`<URL>/media/<token>`)을 통해 LINE에 전달됩니다. 보낸 이미지는 `<DSH_HOME>/line-bot/media/`에 90일간 보관되므로 나중에 채팅을 열어도 LINE이 가져갈 수 있습니다.
219
219
  - quick tunnel은 재시작하면 URL이 바뀌므로 이전 메시지의 이미지는 표시되지 않습니다. 영구 URL이 필요하면 API Token named tunnel과 사용자 도메인을 사용하세요.
220
220
  - 패널의 「이모티콘」 카드에서 켜고 끄기, 답장당 최대 장수(1~3), 라이브러리 샘플 미리보기를 할 수 있습니다. 이미지 추가나 분류 변경은 dsh-meme 설정에서 하세요.
221
- - LINE 스티커를 받으면 스티커 이미지를 가져와 모델에 보여 줍니다(이미지 지원 모델은 바로 읽고, 아니면 LINE 키워드로 대체). 「LINE 자격 증명」 카드에서 「스티커 이해」를 끌 수 있습니다.
221
+ - LINE 스티커를 받으면 스티커 이미지를 가져와 모델에 보여 줍니다. 이미지 지원 모델은 바로 읽고, 아니면 먼저 modlens, 다음으로 다른 비전 모델로 설명하고, 마지막에 LINE 키워드로 대체합니다. 「LINE 자격 증명」 카드에서 「스티커 이해」를 끌 수 있습니다.
222
+ - 일반 이미지도 같습니다. LINE content API로 내려받아 모델에 첨부하고, 이미지 미지원이면 같은 modlens/비전 모델 설명 경로를 씁니다. 「LINE 자격 증명」 카드에서 「이미지 이해」를 끌 수 있습니다.
222
223
  - dsh-meme이 설치되어 있지 않으면 이 기능은 꺼지고 텍스트만 답장합니다.
223
224
  ## 진단
224
225
  - 패널의 "고급 설정 → 진단 실행"에서 서버, Tunnel, 공개 URL, webhook, dsh-meme 라이브러리, 이미지 변환 도구, 모델, 최근 오류를 확인할 수 있습니다. "안전 복구"는 tunnel 재연결과 webhook 재설정만 수행합니다.
package/README.md CHANGED
@@ -98,7 +98,7 @@ npx dsh-line-bot install --no-companions
98
98
  | 玩梗 | 文字梗庫:內建種子、貼吧金句、wanjiqi 彈幕梗;三個來源可個別開關、每輪注入幾則、新增/刪除梗 |
99
99
  | 個人記憶 | 每位使用者分開的長期記憶、到期自動刪、每日請角色整理、手動刪除 |
100
100
  | 表情包 | 使用 dsh-meme 圖庫回圖片/貼圖、開關、一次最多幾張、圖庫樣本 |
101
- | 貼圖理解 | 收到 LINE 貼圖時抓圖給模型看(可關閉) |
101
+ | 貼圖與圖片理解 | 收到 LINE 貼圖或一般圖片時抓圖給模型看;模型不吃圖時用 modlens 或另一個視覺模型描述(可關閉) |
102
102
  | DeepSeek 餘額 | 目前餘額、每輪注入、低於門檻時由角色主動提醒、提醒門檻與冷卻(僅 deepseek-account) |
103
103
  | 主動傳訊 | 開關、機率、間隔、每日/每月上限、最短間隔、安靜時段、idle、指定對象、提示詞、立即試傳、統計 |
104
104
  | 使用者 | 每位 LINE 使用者對應的 DSH session、專屬工作資料夾、目前人設(可直接改)、訊息數、上次互動、清空記憶/重設 session |
@@ -220,7 +220,8 @@ Bot 被加進群組(或多人聊天室)後:
220
220
  - 每輪會把圖庫樣本(編號+描述)帶進 context;角色想配圖時在回覆最後加 `[[meme:#編號]]`(或描述),插件再組成 LINE 的 image message。
221
221
  - 圖片透過目前的 Cloudflare 公開網址(`<網址>/media/<token>`)提供給 LINE;quick tunnel 換網址後會自動跟上。
222
222
  - 面板「表情包」卡可以開關、限制一次最多幾張(1~3),並預覽圖庫樣本。要新增圖片或改分類,請用 dsh-meme 自己的設定。
223
- - 收到 LINE 貼圖時,會抓貼圖圖片交給模型看(支援圖片的模型直接讀圖,否則用 LINE 提供的關鍵字),角色才接得住貼圖的情緒。可在「LINE 憑證」卡關閉「看得懂貼圖」。
223
+ - 收到 LINE 貼圖時,會抓貼圖圖片交給模型看;模型支援圖片就直接讀,否則先用 modlens 描述,再不支援就改用另一個視覺模型,最後才退回 LINE 提供的關鍵字。可在「LINE 憑證」卡關閉「看得懂貼圖」。
224
+ - 一般圖片也一樣:透過 LINE content API 下載後直接附給模型;模型不吃圖時走同一套 modlens/視覺模型描述。可在「LINE 憑證」卡關閉「看得懂一般圖片」。
224
225
  - 沒安裝 dsh-meme 時這個功能會自動停用,只回文字。
225
226
  ## 診斷與修復
226
227
  - 面板「進階設定 → 執行診斷」會檢查伺服器、Tunnel、公開網址、webhook、表情包圖庫、轉圖工具、模型與最近錯誤;「自動修復(安全)」只會重連 tunnel、重設 webhook。
package/README.zh-CN.md CHANGED
@@ -98,7 +98,7 @@ npx dsh-line-bot install --no-companions
98
98
  | 玩梗 | 文字梗库:内置种子、贴吧金句、wanjiqi 弹幕梗;三个来源可分别开关、每轮注入几条、新增/删除梗 |
99
99
  | 个人记忆 | 每位用户分开的长期记忆、到期自动删除、每日请角色整理、手动删除 |
100
100
  | 表情包 | 使用 dsh-meme 图库回图片/贴图、开关、每次最多几张、图库样本 |
101
- | 贴图理解 | 收到 LINE 贴图时抓图给模型看(可关闭) |
101
+ | 贴图与图片理解 | 收到 LINE 贴图或一般图片时抓图给模型看;模型不吃图时用 modlens 或另一个视觉模型描述(可关闭) |
102
102
  | DeepSeek 余额 | 当前余额、每轮注入、低于门槛时由角色主动提醒、提醒门槛与冷却(仅 deepseek-account) |
103
103
  | 主动传讯 | 开关、概率、间隔、每日/每月上限、最短间隔、安静时段、idle、指定对象、提示词、立即试传、统计 |
104
104
  | 用户 | 每位 LINE 用户对应的 DSH session、专属工作文件夹、目前人设(可直接改)、消息数、上次互动、清空记忆/重设 session |
@@ -220,7 +220,8 @@ Bot 被加入群组(或多人聊天室)后:
220
220
  - 每轮会把图库样本(编号+描述)带进 context;角色想配图时在回复最后加 `[[meme:#编号]]`(或描述),插件再组成 LINE 的 image message。
221
221
  - 图片通过当前的 Cloudflare 公开网址(`<网址>/media/<token>`)提供给 LINE;quick tunnel 换网址后会自动跟上。
222
222
  - 面板「表情包」卡可以开关、限制每次最多几张(1~3),并预览图库样本。要新增图片或改分类,请用 dsh-meme 自己的设置。
223
- - 收到 LINE 贴图时,会抓贴图图片交给模型看(支持图片的模型直接读图,否则用 LINE 提供的关键词),角色才能接住贴图的情绪。可在「LINE 凭证」卡关闭「看得懂贴图」。
223
+ - 收到 LINE 贴图时,会抓贴图图片交给模型看;模型支持图片就直接读,否则先用 modlens 描述,再不支持就改用另一个视觉模型,最后才退回 LINE 提供的关键词。可在「LINE 凭证」卡关闭「看得懂贴图」。
224
+ - 一般图片也一样:通过 LINE content API 下载后直接附给模型;模型不吃图时走同一套 modlens/视觉模型描述。可在「LINE 凭证」卡关闭「看得懂一般图片」。
224
225
  - 没安装 dsh-meme 时该功能会自动停用,只回文字。
225
226
  ## 诊断与修复
226
227
  - 面板「高级设置 → 执行诊断」会检查服务器、Tunnel、公开网址、webhook、表情包图库、转图工具、模型与最近错误;「自动修复(安全)」只会重连 tunnel、重设 webhook。
@@ -25,11 +25,12 @@ npx dsh-line-bot doctor --fix # 安全修復:重連 tunnel、重設 webhook
25
25
  - 用面板「測試 Agent 回覆」或 `meme-preview` 確認模型有輸出標記。
26
26
 
27
27
  ### 回覆裡出現 `[[meme:…]]` 或 `<meme#18>`
28
- - 舊版不認得標記格式;升級到 0.6.1 以上(目前 0.8.x)。
28
+ - 舊版不認得標記格式;升級到 0.6.1 以上(目前 0.9.x)。
29
29
 
30
- ### 貼圖看不懂
31
- - 需要模型支援圖片(`inputModalities` 含 `image`)。面板「模型與接口來源」可換模型。
32
- - 模型不支援時仍會用貼圖關鍵字,不會壞掉。
30
+ ### 貼圖/圖片看不懂
31
+ - 模型支援圖片(`inputModalities` 含 `image`)時會直接看圖;面板「模型與接口來源」可換模型。
32
+ - 模型不支援時,插件會先用 modlens 描述,再改用另一個視覺模型,最後才退回貼圖關鍵字。
33
+ - modlens 可在「進階 → 診斷」看 `vision` check;失敗通常是 provider 額度或 503。一般圖片可在面板「LINE 憑證」關閉「看得懂一般圖片」。
33
34
 
34
35
  ### APNG 不會動
35
36
  - LINE 不播放 APNG,只顯示第一幀。保持 `stickers.apng=false` 即可。
package/client.js CHANGED
@@ -759,7 +759,7 @@ window.__ModuleLoader__.load({
759
759
  const selectedPersona = personaCatalog.find((item) => item.id === personaCfg.id) || null
760
760
  const selectedName = selectedPersona?.name || personaCfg.id || '大肥魚'
761
761
  const selectedIsLocal = String(personaCfg.id || '').startsWith('local:')
762
- const syncPersonaOptions = personaOptions.slice()
762
+ const syncPersonaOptions = personaCatalog.slice()
763
763
  const syncTarget = syncCfg.targetPersonaId || (syncPersonaOptions[0] && syncPersonaOptions[0].id) || ''
764
764
  const bot = status.line?.bot || null
765
765
  const webhook = status.line?.webhook || null
@@ -968,7 +968,9 @@ window.__ModuleLoader__.load({
968
968
  ),
969
969
  h(Toggle, { checked: line.useAutoToken !== false, onChange: (value) => update({ line: { useAutoToken: value } }), label: '自動簽發 access token', hint: '有 Channel ID+Secret 時,優先自動取得(建議開啟)' }),
970
970
  h(Toggle, { checked: line.loadingEnabled !== false, onChange: (value) => update({ line: { loadingEnabled: value } }), label: '顯示「正在輸入」動畫', hint: '回覆前呼叫 LINE 的 loading 動畫' }),
971
- h(Toggle, { checked: line.stickerVision !== false, onChange: (value) => update({ line: { stickerVision: value } }), label: '看得懂貼圖', hint: '收到貼圖時抓圖片給模型看;模型不支援圖片時仍會用貼圖關鍵字' }),
971
+ h(Toggle, { checked: line.stickerVision !== false, onChange: (value) => update({ line: { stickerVision: value } }), label: '看得懂貼圖', hint: '收到貼圖時抓圖片給模型看;模型不支援圖片時改用 modlens 描述,再不支援才用關鍵字' }),
972
+ h(Toggle, { checked: line.imageVision !== false, onChange: (value) => update({ line: { imageVision: value } }), label: '看得懂一般圖片', hint: '收到圖片時下載給模型看;模型不支援圖片時用 modlens 產生描述' }),
973
+ h('div', { style: S.note }, `看圖方式:${runtime.vision?.modelSupportsImages ? '目前模型可直接看圖' : runtime.vision?.available ? '目前模型不吃圖,改用 modlens 描述' : '目前模型不吃圖,且找不到 modlens'}${runtime.vision?.lastError ? `(modlens 最近錯誤:${runtime.vision.lastError})` : ''}`),
972
974
  h('div', { style: S.row },
973
975
  h(Btn, { onClick: () => act('line-verify'), disabled: !!busy, tone: 'primary' }, busy === 'line-verify' ? '驗證中…' : '驗證並讀取 bot 資訊'),
974
976
  h(Btn, { onClick: () => act('token-refresh'), disabled: !!busy }, '重新簽發 token'),
@@ -1077,7 +1079,7 @@ window.__ModuleLoader__.load({
1077
1079
  onChange: (event) => update({ agent: { model: event.target.value } }),
1078
1080
  }),
1079
1081
  h('datalist', { id: 'line-bot-model-options' },
1080
- ((modelProviders.find((item) => item.id === (draft.agent.provider || agentStatus.defaultProvider)) || {}).models || []).map((item) => h('option', { key: item.id, value: item.id }, item.name))),
1082
+ ((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 ? '(內建)' : ''))))),
1081
1083
  ),
1082
1084
  h(Field, { label: '推理強度(選填)', hint: '留空=跟隨 provider 預設' },
1083
1085
  h(Text, { value: draft.agent.reasoningEffort || '', onChange: (value) => update({ agent: { reasoningEffort: value } }), placeholder: 'max / high / medium' })),
@@ -1160,7 +1162,7 @@ window.__ModuleLoader__.load({
1160
1162
  { id: sectionDomId('sync'), order: 6, title: '貼吧學習同步', desc: '把「刷貼吧」每天演化學到的語氣樣本,寫進上方人設。自訂人設會直接改人設本文;內建人設(大肥魚、阿茶、听澜)不能改原文,會寫進插件自己的覆蓋層,每輪注入、不用重載。' },
1161
1163
  h(Toggle, { checked: syncCfg.enabled !== false, onChange: (value) => update({ sync: { enabled: value } }), label: '開啟自動同步', hint: '每小時檢查一次;貼吧人設版本有變才寫入(寫入前會備份 personas.json)' }),
1162
1164
  h('div', { style: S.grid },
1163
- h(Field, { label: '同步到哪個人設', hint: '內建人設使用覆蓋層;自訂人設直接改本文' },
1165
+ h(Field, { label: '同步到哪個人設', hint: '內建人設與插件自訂人設使用覆蓋層;大肥魚的自訂人設直接改本文' },
1164
1166
  h('select', { value: syncTarget, onChange: (event) => update({ sync: { targetPersonaId: event.target.value } }), style: S.input },
1165
1167
  syncPersonaOptions.length
1166
1168
  ? syncPersonaOptions.map((item) => h('option', { key: item.id, value: item.id }, item.name))
package/lib/config.mjs CHANGED
@@ -26,6 +26,7 @@ export const DEFAULT_CONFIG = {
26
26
  useAutoToken: true,
27
27
  loadingEnabled: true,
28
28
  stickerVision: true,
29
+ imageVision: true,
29
30
  allowedUserIds: [],
30
31
  welcomeText: '(已加好友)嗨,我是大肥魚~想聊什麼都可以跟我說。',
31
32
  errorReply: '(剛剛卡住了,再說一次好嗎?)',
@@ -196,6 +197,7 @@ export function sanitizePatch(current, patch) {
196
197
  next.line.allowedUserIds = safeArray(next.line.allowedUserIds)
197
198
  next.line.loadingEnabled = Boolean(next.line.loadingEnabled)
198
199
  next.line.stickerVision = Boolean(next.line.stickerVision)
200
+ next.line.imageVision = Boolean(next.line.imageVision)
199
201
  next.line.welcomeText = String(next.line.welcomeText ?? '').slice(0, 1000)
200
202
  next.line.errorReply = String(next.line.errorReply ?? '').slice(0, 500)
201
203
  next.line.emptyReply = String(next.line.emptyReply ?? '').slice(0, 500)
package/lib/host.mjs CHANGED
@@ -27,8 +27,9 @@ const jokesMod = await import(withVersion('./jokes.mjs'))
27
27
  const stickersMod = await import(withVersion('./stickers.mjs'))
28
28
  const memeSourceMod = await import(withVersion('./meme-source.mjs'))
29
29
  const stickerImageMod = await import(withVersion('./sticker-image.mjs'))
30
+ const visionMod = await import(withVersion('./vision.mjs'))
30
31
  const utilMod = await import(withVersion('./util.mjs'))
31
- const { createConfigStore, publicConfig, dataDir } = configMod
32
+ const { createConfigStore, publicConfig, dataDir, dshHome } = configMod
32
33
  const { createPersonaClient } = personaMod
33
34
  const { createAgentRunner } = runnerMod
34
35
  const { createWebhookServer } = webhookMod
@@ -43,9 +44,10 @@ const { createJokeStore } = jokesMod
43
44
  const { createStickerStore } = stickersMod
44
45
  const { createMemeSource } = memeSourceMod
45
46
  const { createStickerImages } = stickerImageMod
46
- const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody, safeArray, sleep } = utilMod
47
+ const { createVision } = visionMod
48
+ const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody, safeArray, sleep, readJsonSafe } = utilMod
47
49
 
48
- export const VERSION = '0.9.0'
50
+ export const VERSION = '0.9.2'
49
51
 
50
52
  // 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
51
53
  // 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
@@ -161,17 +163,17 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
161
163
  } catch (error) {
162
164
  logger.warn(`請大肥魚讓開失敗(自訂人設仍會注入):${errorText(error)}`)
163
165
  }
164
- return { persona: null, personaText: resolved.text, source: 'local', name: resolved.name }
166
+ return { persona: null, personaText: resolved.text, source: 'local', name: resolved.name, selection: resolved.id }
165
167
  }
166
168
  if (resolved.kind === 'dafeiyu') {
167
- return { persona: resolved.id, personaText: null, source: 'dafeiyu', name: '' }
169
+ return { persona: resolved.id, personaText: null, source: 'dafeiyu', name: '', selection: resolved.id }
168
170
  }
169
- return { persona: null, personaText: null, source: '', name: '' }
171
+ return { persona: null, personaText: null, source: '', name: '', selection: '' }
170
172
  }
171
173
 
172
174
  // 內建人設(大肥魚等)不能改原文,貼吧同步會寫在覆蓋層;每輪注入在 persona 之後。
173
175
  function personaOverlayText(applied) {
174
- const id = applied?.persona
176
+ const id = applied?.selection || applied?.persona
175
177
  if (!id) return ''
176
178
  try {
177
179
  return readPersonaOverlay(id)
@@ -1478,25 +1480,90 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1478
1480
  const keywords = Array.isArray(event.message.keywords)
1479
1481
  ? event.message.keywords.filter(Boolean).slice(0, 8).join('、')
1480
1482
  : ''
1483
+ const supportsImages = await modelSupportsImages()
1481
1484
  let hasImage = false
1485
+ let described = ''
1482
1486
  if (cfg.line?.stickerVision !== false) {
1483
1487
  try {
1484
- const ref = await stickerImages.toAttachment(event.message.stickerId, event.message.stickerResourceType)
1485
- if (ref) {
1486
- messageImages.push(ref)
1487
- hasImage = true
1488
+ if (supportsImages) {
1489
+ const ref = await stickerImages.toAttachment(event.message.stickerId, event.message.stickerResourceType)
1490
+ if (ref) {
1491
+ messageImages.push(ref)
1492
+ hasImage = true
1493
+ }
1494
+ } else {
1495
+ let description = ''
1496
+ const { data } = await stickerImages.fetchBytes(event.message.stickerId, event.message.stickerResourceType)
1497
+ const file = writeIncomingImage(Buffer.from(data), `sticker-${event.message.stickerId}-${Date.now()}.png`)
1498
+ if (vision.available()) {
1499
+ try {
1500
+ description = await describeImageFile(file, '這是一張 LINE 貼圖,請用繁體中文簡短描述圖案與情緒。')
1501
+ } catch (error) {
1502
+ logger.warn(`modlens 看貼圖失敗,改用視覺模型:${errorText(error)}`)
1503
+ }
1504
+ }
1505
+ if (!description) {
1506
+ try {
1507
+ const ref = await stickerImages.saveBuffer(Buffer.from(data), `line-sticker-${event.message.stickerId}.png`, 'image/png')
1508
+ description = await describeWithVisionModel(ref)
1509
+ } catch (error) {
1510
+ logger.warn(`視覺模型看貼圖失敗:${errorText(error)}`)
1511
+ }
1512
+ }
1513
+ described = description
1488
1514
  }
1489
1515
  } catch (error) {
1490
- logger.warn(`取得貼圖圖片失敗,只用關鍵字:${errorText(error)}`)
1516
+ logger.warn(`取得貼圖圖片或看圖失敗,只用關鍵字:${errorText(error)}`)
1491
1517
  }
1492
1518
  }
1493
1519
  text = [
1494
1520
  `(使用者沒有打字,傳了一個 LINE 貼圖:packageId=${event.message.packageId ?? '?'}, stickerId=${event.message.stickerId ?? '?'}`,
1495
1521
  keywords ? `,貼圖關鍵字:${keywords}` : '',
1496
- hasImage ? ',貼圖圖片已附在這一則訊息裡(請直接看圖)' : '',
1522
+ hasImage ? ',貼圖圖片已附在這一則訊息裡(請直接看圖)' : described ? `,貼圖內容描述:${described}` : '',
1497
1523
  '。請用你自己的語氣,一兩句自然回應這個貼圖;不要說「你傳了貼圖」這種話,依圖片內容或關鍵字猜情緒,不確定就輕鬆帶過。)',
1498
1524
  ].join('')
1499
- logger.info(`收到貼圖(packageId=${event.message.packageId ?? '?'}, stickerId=${event.message.stickerId ?? '?'}${keywords ? `,${keywords}` : ''})`)
1525
+ logger.info(`收到貼圖(packageId=${event.message.packageId ?? '?'}, stickerId=${event.message.stickerId ?? '?'}${keywords ? `,${keywords}` : ''}${hasImage ? ',已附圖' : described ? ',已取得圖片描述' : ''})`)
1526
+ } else if (messageType === 'image') {
1527
+ if (cfg.line?.imageVision === false) {
1528
+ logger.info('略過圖片(line.imageVision 關閉)')
1529
+ return
1530
+ }
1531
+ try {
1532
+ const messageId = event.message?.id
1533
+ if (!messageId) throw new Error('缺少 messageId')
1534
+ const buffer = await withToken((token) => lineApi.getMessageContent(token, messageId))
1535
+ const mediaType = imageMediaType(buffer)
1536
+ if (await modelSupportsImages()) {
1537
+ const ref = await stickerImages.saveBuffer(buffer, `line-image-${messageId}.${mediaType.split('/')[1]}`, mediaType)
1538
+ messageImages.push(ref)
1539
+ text = '(使用者傳了一張圖片,圖片已附在這一則訊息裡,請直接看圖並用你自己的語氣自然回應;不要說「你傳了圖片」這種話。)'
1540
+ } else {
1541
+ let description = ''
1542
+ if (vision.available()) {
1543
+ try {
1544
+ const file = writeIncomingImage(buffer, `incoming-${messageId}.${mediaType.split('/')[1]}`)
1545
+ description = await describeImageFile(file, '這是一張 LINE 對話中傳來的圖片,請用繁體中文簡潔描述內容與情緒。')
1546
+ } catch (error) {
1547
+ logger.warn(`modlens 看圖片失敗,改用視覺模型:${errorText(error)}`)
1548
+ }
1549
+ }
1550
+ if (!description) {
1551
+ try {
1552
+ const ref = await stickerImages.saveBuffer(buffer, `line-image-${messageId}.${mediaType.split('/')[1]}`, mediaType)
1553
+ description = await describeWithVisionModel(ref)
1554
+ } catch (error) {
1555
+ logger.warn(`視覺模型看圖片失敗:${errorText(error)}`)
1556
+ }
1557
+ }
1558
+ text = description
1559
+ ? `(使用者傳了一張圖片。圖片內容描述:${description}。請依這個描述用你自己的語氣自然回應,不要說「你傳了圖片」這種話。)`
1560
+ : '(使用者傳了一張圖片,但目前模型不支援看圖、modlens 也不可用;請用文字輕輕帶過,不要假裝看到內容。)'
1561
+ }
1562
+ logger.info(`收到圖片(messageId=${messageId},${buffer.length} bytes)`)
1563
+ } catch (error) {
1564
+ logger.warn(`處理圖片失敗:${errorText(error)}`)
1565
+ text = '(使用者傳了一張圖片,但下載或辨識失敗;請用文字輕輕帶過,不要假裝看到內容。)'
1566
+ }
1500
1567
  } else {
1501
1568
  logger.info(`略過非文字訊息(${messageType})`)
1502
1569
  return
@@ -1609,7 +1676,98 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1609
1676
  const stickers = createStickerStore({ dir, logger })
1610
1677
  const memes = createMemeSource({ ctx, logger })
1611
1678
  const stickerImages = createStickerImages({ ctx, logger, dir })
1679
+ const vision = createVision({ logger, cacheDir: path.join(dir, 'cache') })
1612
1680
  const mediaTokens = new Map()
1681
+ let imageSupportCache = { at: 0, key: '', value: false }
1682
+
1683
+ async function modelSupportsImages() {
1684
+ const selection = modelSelection()
1685
+ const key = `${selection.provider}/${selection.model}`
1686
+ if (imageSupportCache.key === key && Date.now() - imageSupportCache.at < 60_000) return imageSupportCache.value
1687
+ let value = false
1688
+ try {
1689
+ const llm = ctx.get?.('llm')
1690
+ if (llm?.resolveModelInfo && selection.provider && selection.model) {
1691
+ const info = await llm.resolveModelInfo(selection.provider, selection.model)
1692
+ value = Array.isArray(info?.inputModalities) && info.inputModalities.includes('image')
1693
+ }
1694
+ if (!value) {
1695
+ const catalog = await modelCatalog({ force: false }).catch(() => null)
1696
+ const provider = catalog?.providers?.find((item) => item.id === selection.provider)
1697
+ const model = provider?.models?.find((item) => item.id === selection.model)
1698
+ value = Array.isArray(model?.inputModalities) && model.inputModalities.includes('image')
1699
+ }
1700
+ } catch (error) {
1701
+ logger.warn(`查模型圖片能力失敗:${errorText(error)}`)
1702
+ }
1703
+ imageSupportCache = { at: Date.now(), key, value }
1704
+ return value
1705
+ }
1706
+
1707
+ function imageMediaType(buffer) {
1708
+ const bytes = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer || [])
1709
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
1710
+ if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50) return 'image/png'
1711
+ if (bytes.length >= 12 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'
1712
+ if (bytes.length >= 6 && bytes.toString('ascii', 0, 3) === 'GIF') return 'image/gif'
1713
+ return 'image/png'
1714
+ }
1715
+
1716
+ function writeIncomingImage(buffer, name) {
1717
+ const file = path.join(dir, 'cache', name)
1718
+ fs.mkdirSync(path.dirname(file), { recursive: true })
1719
+ fs.writeFileSync(file, buffer)
1720
+ return file
1721
+ }
1722
+
1723
+ async function describeImageFile(file, prompt) {
1724
+ const described = await vision.describe(file, { prompt })
1725
+ return described.text
1726
+ }
1727
+
1728
+ async function describeWithVisionModel(ref) {
1729
+ const llm = ctx.get?.('llm')
1730
+ if (!llm?.stream || !ref) return ''
1731
+ const catalog = await modelCatalog({ force: false }).catch(() => null)
1732
+ const wanted = [
1733
+ ['cguair', 'gpt-6.1-terra'],
1734
+ ['cguair', 'gpt-6.1-sol'],
1735
+ ['deepseek-account', 'deepseek-flash'],
1736
+ ['deepseek-official', 'deepseek-flash'],
1737
+ ['deepseek-modlens', 'deepseek-v4-pro'],
1738
+ ]
1739
+ const available = []
1740
+ for (const [provider, model] of wanted) {
1741
+ const item = catalog?.providers?.find((entry) => entry.id === provider)?.models?.find((entry) => entry.id === model)
1742
+ if (item && Array.isArray(item.inputModalities) && item.inputModalities.includes('image')) available.push({ provider, model })
1743
+ }
1744
+ for (const entry of available) {
1745
+ try {
1746
+ let text = ''
1747
+ const stream = llm.stream({
1748
+ provider: entry.provider,
1749
+ model: entry.model,
1750
+ maxTokens: 220,
1751
+ messages: [
1752
+ {
1753
+ role: 'user',
1754
+ content: [
1755
+ { type: 'text', text: '請用繁體中文簡短描述這張圖片的內容與情緒,兩句以內,不要客套。' },
1756
+ { type: 'image', attachment: ref },
1757
+ ],
1758
+ },
1759
+ ],
1760
+ })
1761
+ for await (const chunk of stream) {
1762
+ if (chunk?.type === 'text-delta') text += chunk.text || ''
1763
+ }
1764
+ if (text.trim()) return text.trim()
1765
+ } catch (error) {
1766
+ logger.warn(`用 ${entry.provider}/${entry.model} 看圖失敗:${errorText(error)}`)
1767
+ }
1768
+ }
1769
+ return ''
1770
+ }
1613
1771
 
1614
1772
  const server = createWebhookServer({ getConfig: () => store.get(), logger, dispatch: handleEvent, getMedia, getDoctor: (options) => runDiagnostics(options) })
1615
1773
 
@@ -1675,6 +1833,33 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1675
1833
 
1676
1834
  // ── 人設摘要(給面板) ───────────────────────────────────────────────────
1677
1835
  let personaCache = { at: 0, value: null }
1836
+ function readDafeiyuStoreFallback() {
1837
+ const builtins = [
1838
+ { id: 'dafeiyu', name: '大肥魚', chars: 635 },
1839
+ { id: 'acha', name: '阿茶', chars: 979 },
1840
+ { id: 'tinglan', name: '听澜', chars: 1738 },
1841
+ ].map((item) => ({ ...item, builtin: true, enabled: true, description: '', source: 'dafeiyu' }))
1842
+ const list = builtins
1843
+ try {
1844
+ const file = path.join(dshHome(), 'storages', 'dsh-persona-dafeiyu', 'personas.json')
1845
+ const raw = readJsonSafe(file, null)
1846
+ for (const item of raw?.personas || []) {
1847
+ const id = String(item?.id || '').trim()
1848
+ if (!id) continue
1849
+ list.push({
1850
+ id,
1851
+ name: String(item.name || id),
1852
+ builtin: false,
1853
+ enabled: true,
1854
+ chars: String(item.text || '').length,
1855
+ description: '直接從 personas.json 讀取',
1856
+ source: 'dafeiyu',
1857
+ })
1858
+ }
1859
+ } catch { /* 讀不到就只列內建 */ }
1860
+ return list
1861
+ }
1862
+
1678
1863
  async function getPersonaSummary({ force = false } = {}) {
1679
1864
  const cfg = store.get()
1680
1865
  if (!force && personaCache.value && Date.now() - personaCache.at < 5000) return personaCache.value
@@ -1722,7 +1907,11 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1722
1907
  ]
1723
1908
  } catch (error) {
1724
1909
  summary.error = errorText(error)
1725
- summary.catalog = [...local]
1910
+ const fallback = readDafeiyuStoreFallback()
1911
+ summary.list = fallback
1912
+ summary.mode = 'unknown'
1913
+ summary.catalog = [...fallback, ...local]
1914
+ summary.warning = '讀不到大肥魚外掛的 HTTP bridge,已直接讀取 personas.json;清單可能較舊,寫入後請重啟大肥魚外掛。'
1726
1915
  }
1727
1916
  const selected = summary.catalog.find((item) => item.id === cfg.persona.id)
1728
1917
  summary.selectedName = selected?.name || ''
@@ -1730,7 +1919,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1730
1919
  summary.warning = selectedSource === 'local'
1731
1920
  ? `找不到自訂人設「${cfg.persona.id}」,LINE 會改用預設人設。`
1732
1921
  : `找不到人設「${cfg.persona.id}」,LINE 會退回大肥魚的全域人設。`
1733
- } else if (selectedSource === 'dafeiyu' && cfg.persona.enforce && summary.mode !== 'session') {
1922
+ } else if (!summary.error && selectedSource === 'dafeiyu' && cfg.persona.enforce && summary.mode !== 'session') {
1734
1923
  summary.warning = `大肥魚目前是「${summary.mode || '未知'}」模式;LINE 的逐 session 人設指定要切到 session 模式才會生效。`
1735
1924
  }
1736
1925
  personaCache = { at: Date.now(), value: summary }
@@ -1846,10 +2035,16 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1846
2035
  const modalities = info?.inputModalities || []
1847
2036
  checks.push(check('model', '回覆模型', 'ok',
1848
2037
  `${selected.provider}/${selected.model}${modalities.length ? `(${modalities.join('+')})` : ''}`,
1849
- modalities.includes('image') ? '' : '這個模型不吃圖;貼圖理解會退回關鍵字,但不影響送表情包'))
2038
+ modalities.includes('image') ? '' : '這個模型不吃圖;會改用 modlens 或另一個視覺模型描述圖片,失敗才退回關鍵字'))
1850
2039
  } catch (error) {
1851
2040
  checks.push(check('model', '回覆模型', 'warn', errorText(error), '在面板「模型與接口來源」重新選模型'))
1852
2041
  }
2042
+ {
2043
+ const status = vision.status()
2044
+ checks.push(check('vision', '看圖能力(modlens)', status.available ? 'ok' : 'warn',
2045
+ status.available ? `modlens 可用${status.lastError ? `;最近錯誤:${status.lastError}` : ''}` : '找不到 modlens;模型不吃圖時會退回關鍵字',
2046
+ status.available ? '' : '安裝 @liustack/modlens,或改用支援圖片的模型'))
2047
+ }
1853
2048
  if (memeStats.available && base) {
1854
2049
  try {
1855
2050
  const sample = memes.sample({ limit: 1 })[0]
@@ -2005,6 +2200,12 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
2005
2200
  items: stickers.list(),
2006
2201
  warning: publicBaseSync() ? '' : '尚未取得公開網址(Cloudflare tunnel 未啟動)',
2007
2202
  },
2203
+ vision: {
2204
+ ...vision.status(),
2205
+ modelSupportsImages: await modelSupportsImages(),
2206
+ imageVision: cfg.line?.imageVision !== false,
2207
+ stickerVision: cfg.line?.stickerVision !== false,
2208
+ },
2008
2209
  memory: {
2009
2210
  ...memory.stats(),
2010
2211
  enabled: cfg.memory?.enabled !== false,
@@ -2228,6 +2429,47 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
2228
2429
  return maybeAskForTopUp({ dryRun: payload.dryRun !== false, force: payload.force === true })
2229
2430
  case 'meme-stats':
2230
2431
  return { ...memes.stats(), enabled: store.get().stickers?.enabled !== false, maxPerReply: memeMaxPerReply(), sample: memes.sample({ limit: 20 }) }
2432
+ case 'vision-model-look': {
2433
+ const url = String(payload.url || '').trim()
2434
+ if (!url) throw new Error('缺少 url')
2435
+ const response = await fetch(url, { signal: AbortSignal.timeout(20_000) })
2436
+ if (!response.ok) throw new Error(`下載圖片失敗:HTTP ${response.status}`)
2437
+ const buffer = Buffer.from(await response.arrayBuffer())
2438
+ const mediaType = imageMediaType(buffer)
2439
+ const ref = await stickerImages.saveBuffer(buffer, `vision-model-${Date.now()}.${mediaType.split('/')[1]}`, mediaType)
2440
+ return { text: await describeWithVisionModel(ref), attachmentId: String(ref.attachmentId || '') }
2441
+ }
2442
+ case 'image-look': {
2443
+ const url = String(payload.url || '').trim()
2444
+ if (!url) throw new Error('缺少 url')
2445
+ const response = await fetch(url, { signal: AbortSignal.timeout(20_000) })
2446
+ if (!response.ok) throw new Error(`下載圖片失敗:HTTP ${response.status}`)
2447
+ const buffer = Buffer.from(await response.arrayBuffer())
2448
+ if (buffer.length > 10 * 1024 * 1024) throw new Error('圖片超過 10MB')
2449
+ const mediaType = imageMediaType(buffer)
2450
+ const ref = await stickerImages.saveBuffer(buffer, `test-image-${Date.now()}.${mediaType.split('/')[1]}`, mediaType)
2451
+ const result = await runner.turn({
2452
+ sessionId: `session-image-look-${Date.now()}`,
2453
+ text: '請用一句繁體中文描述這張圖片的內容與情緒。',
2454
+ images: [ref],
2455
+ contextText: '',
2456
+ cwd: store.get().agent.cwd || undefined,
2457
+ })
2458
+ return { text: result.text, attachmentId: String(ref.attachmentId || ''), mediaType }
2459
+ }
2460
+ case 'vision-describe': {
2461
+ const url = String(payload.url || '').trim()
2462
+ const stickerId = String(payload.stickerId || '').trim()
2463
+ const file = String(payload.file || '').trim()
2464
+ if (!url && !stickerId && !file) throw new Error('需要 url、stickerId 或 file')
2465
+ let target = url || file
2466
+ if (stickerId) {
2467
+ const { data } = await stickerImages.fetchBytes(stickerId, payload.stickerResourceType || 'STATIC')
2468
+ target = writeIncomingImage(Buffer.from(data), `vision-${stickerId}-${Date.now()}.png`)
2469
+ }
2470
+ const described = await vision.describe(target, { prompt: String(payload.prompt || '').trim() })
2471
+ return { text: described.text, vision: vision.status() }
2472
+ }
2231
2473
  case 'sticker-look': {
2232
2474
  const stickerId = String(payload.stickerId || '').trim()
2233
2475
  if (!stickerId) throw new Error('缺少 stickerId')
package/lib/line-api.mjs CHANGED
@@ -161,3 +161,32 @@ export async function pushMessage(accessToken, to, content) {
161
161
  body: JSON.stringify({ to, messages }),
162
162
  })
163
163
  }
164
+
165
+ /**
166
+ * 下載使用者傳來的圖片/檔案內容(GET /v2/bot/message/{id}/content)。
167
+ * LINE 的 content API 在 api-data 網域,回傳二進位。
168
+ */
169
+ export async function getMessageContent(accessToken, messageId, { timeoutMs = 30000 } = {}) {
170
+ const id = String(messageId || '').trim()
171
+ if (!id) throw new LineApiError('沒有 messageId')
172
+ const controller = new AbortController()
173
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
174
+ try {
175
+ const res = await fetch(`https://api-data.line.me/v2/bot/message/${encodeURIComponent(id)}/content`, {
176
+ headers: { authorization: `Bearer ${accessToken}` },
177
+ signal: controller.signal,
178
+ })
179
+ if (!res.ok) {
180
+ const text = await res.text().catch(() => '')
181
+ throw new LineApiError(`LINE ${res.status}${text ? `:${text.slice(0, 200)}` : ''}`, { status: res.status })
182
+ }
183
+ const data = Buffer.from(await res.arrayBuffer())
184
+ if (data.length > 10 * 1024 * 1024) throw new LineApiError('圖片超過 10MB,略過')
185
+ return data
186
+ } catch (error) {
187
+ if (error?.name === 'AbortError') throw new LineApiError('LINE 圖片下載逾時', { status: 0 })
188
+ throw error
189
+ } finally {
190
+ clearTimeout(timer)
191
+ }
192
+ }
@@ -49,5 +49,14 @@ export function createStickerImages({ ctx, logger, dir }) {
49
49
  return ref
50
50
  }
51
51
 
52
- return { available, toAttachment }
52
+ async function saveBuffer(buffer, name = 'line-image.png', mediaType = 'image/png') {
53
+ if (!available()) throw new Error('這個 profile 沒有附件服務(attachments)')
54
+ if (!buffer || buffer.byteLength === 0) throw new Error('圖片是空檔案')
55
+ const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
56
+ const [ref] = await attachments.saveImages([{ data: bytes, mediaType, name }])
57
+ if (!ref) throw new Error('附件服務沒有回傳圖片參照')
58
+ return ref
59
+ }
60
+
61
+ return { available, toAttachment, saveBuffer, fetchBytes }
53
62
  }
@@ -223,8 +223,9 @@ export function createTiebaSync({ ctx, store, logger, getCatalog }) {
223
223
  const list = await catalog()
224
224
  const info = list.find((item) => item.id === targetId)
225
225
  const builtin = Boolean(info?.builtin) || ['dafeiyu', 'acha', 'tinglan'].includes(targetId)
226
+ const overlayMode = builtin || String(targetId).startsWith('local:') || info?.source === 'local'
226
227
  const section = buildSection(tieba)
227
- if (builtin) {
228
+ if (overlayMode) {
228
229
  const oldText = readPersonaOverlay(targetId)
229
230
  return {
230
231
  ok: true,
@@ -269,6 +270,7 @@ export function createTiebaSync({ ctx, store, logger, getCatalog }) {
269
270
  const list = await catalog()
270
271
  const info = list.find((item) => item.id === targetId)
271
272
  const builtin = Boolean(info?.builtin) || ['dafeiyu', 'acha', 'tinglan'].includes(targetId)
273
+ const overlayMode = builtin || String(targetId).startsWith('local:') || info?.source === 'local'
272
274
  const section = buildSection(tieba)
273
275
  const at = Date.now()
274
276
  const stateSync = store.getState().sync || {}
@@ -277,7 +279,7 @@ export function createTiebaSync({ ctx, store, logger, getCatalog }) {
277
279
  let reload = null
278
280
  let sectionText = section
279
281
 
280
- if (builtin) {
282
+ if (overlayMode) {
281
283
  const oldText = readPersonaOverlay(targetId)
282
284
  const nextText = applySection(oldText, section).trimEnd() + '\n'
283
285
  changed = sansStamp(oldText) !== sansStamp(section)
@@ -291,7 +293,7 @@ export function createTiebaSync({ ctx, store, logger, getCatalog }) {
291
293
  fs.writeFileSync(backupPath, oldText)
292
294
  }
293
295
  overlayWrite(targetId, nextText)
294
- logger.info(`已把刷貼吧學到的口氣寫進內建人設「${info?.name || targetId}」的覆蓋層`)
296
+ logger.info(`已把刷貼吧學到的口氣寫進「${info?.name || targetId}」的覆蓋層`)
295
297
  }
296
298
  sectionText = nextText
297
299
  } else {
@@ -326,7 +328,7 @@ export function createTiebaSync({ ctx, store, logger, getCatalog }) {
326
328
  at,
327
329
  version: tieba.version,
328
330
  targetId,
329
- mode: builtin ? 'overlay' : 'persona',
331
+ mode: overlayMode ? 'overlay' : 'persona',
330
332
  samples: tieba.samples.length,
331
333
  changed,
332
334
  backupPath,
@@ -335,7 +337,7 @@ export function createTiebaSync({ ctx, store, logger, getCatalog }) {
335
337
  },
336
338
  }))
337
339
 
338
- if (!builtin && changed && cfg.reloadPersona !== false) {
340
+ if (!overlayMode && changed && cfg.reloadPersona !== false) {
339
341
  try {
340
342
  reload = await reloadPersonaPlugin(ctx)
341
343
  if (!reload.ok) logger.warn(`重載大肥魚外掛失敗:${reload.error}`)
@@ -350,7 +352,7 @@ export function createTiebaSync({ ctx, store, logger, getCatalog }) {
350
352
  version: tieba.version,
351
353
  targetId,
352
354
  targetName: info?.name || targetId,
353
- mode: builtin ? 'overlay' : 'persona',
355
+ mode: overlayMode ? 'overlay' : 'persona',
354
356
  changed,
355
357
  samples: tieba.samples.length,
356
358
  backupPath,
package/lib/vision.mjs ADDED
@@ -0,0 +1,100 @@
1
+ // 用 modlens CLI 看圖:只在模型本身不支援圖片、或需要描述時使用。
2
+ import { execFile } from 'node:child_process'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ const TIMEOUT_MS = 90_000
8
+
9
+ function candidateMains() {
10
+ const list = []
11
+ if (process.env.MODLENS_MAIN) list.push(process.env.MODLENS_MAIN)
12
+ const home = os.homedir()
13
+ for (const profile of ['desktop', 'web', 'line', 'headless']) {
14
+ list.push(path.join(home, '.dsh', 'profiles', profile, 'node_modules', '@liustack', 'modlens', 'dist', 'main.js'))
15
+ }
16
+ list.push('/opt/homebrew/lib/node_modules/@liustack/modlens/dist/main.js')
17
+ list.push('/usr/local/lib/node_modules/@liustack/modlens/dist/main.js')
18
+ return list
19
+ }
20
+
21
+ function execFileAsync(file, args, options) {
22
+ return new Promise((resolve, reject) => {
23
+ execFile(file, args, options, (error, stdout, stderr) => {
24
+ if (error) {
25
+ error.stdout = stdout
26
+ error.stderr = stderr
27
+ reject(error)
28
+ return
29
+ }
30
+ resolve({ stdout, stderr })
31
+ })
32
+ })
33
+ }
34
+
35
+ function pickText(data) {
36
+ const parts = []
37
+ if (data?.summary) parts.push(String(data.summary).trim())
38
+ const scene = data?.semantics?.scene
39
+ if (scene && !parts.includes(scene)) parts.push(String(scene).trim())
40
+ const intent = data?.semantics?.intent
41
+ if (intent) parts.push(`意圖:${intent}`)
42
+ const entities = Array.isArray(data?.semantics?.entities) ? data.semantics.entities.map((item) => item?.name).filter(Boolean) : []
43
+ if (entities.length) parts.push(`畫面元素:${entities.slice(0, 6).join('、')}`)
44
+ const ocr = String(data?.ocr?.full_text || '').trim()
45
+ if (ocr) parts.push(`文字:${ocr.slice(0, 240)}`)
46
+ const regions = Array.isArray(data?.layout?.regions) ? data.layout.regions.map((item) => item?.text).filter(Boolean) : []
47
+ if (!parts.length && regions.length) parts.push(regions.slice(0, 8).join(' '))
48
+ const text = parts.filter(Boolean).join(';').replace(/\s+/g, ' ').trim()
49
+ return text.slice(0, 600)
50
+ }
51
+
52
+ export function createVision({ logger, cacheDir }) {
53
+ let mainPath
54
+ let lastError = ''
55
+
56
+ function resolveMain() {
57
+ if (mainPath !== undefined) return mainPath
58
+ mainPath = candidateMains().find((file) => fs.existsSync(file)) || ''
59
+ return mainPath
60
+ }
61
+
62
+ function available() {
63
+ return Boolean(resolveMain())
64
+ }
65
+
66
+ function status() {
67
+ return { available: available(), main: resolveMain(), lastError }
68
+ }
69
+
70
+ async function describe(file, { prompt = '', provider = '', model = '' } = {}) {
71
+ if (!available()) throw new Error('找不到 modlens(請安裝 @liustack/modlens 或設定 MODLENS_MAIN)')
72
+ if (!file) throw new Error('沒有指定圖片')
73
+ if (!/^https?:\/\//i.test(file) && !fs.existsSync(file)) throw new Error('找不到要看的圖片檔案')
74
+ const dir = cacheDir || path.join(os.tmpdir(), 'dsh-line-bot-vision')
75
+ fs.mkdirSync(dir, { recursive: true })
76
+ const out = path.join(dir, `modlens-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.json`)
77
+ const args = ['analyze', '-i', file, '-o', out, '--timeout', String(TIMEOUT_MS)]
78
+ if (provider) args.push('--provider', provider)
79
+ if (model) args.push('--model', model)
80
+ if (prompt) args.push('--prompt', prompt)
81
+ try {
82
+ await execFileAsync(process.execPath, [resolveMain(), ...args], { timeout: TIMEOUT_MS + 15_000 })
83
+ const raw = JSON.parse(fs.readFileSync(out, 'utf8'))
84
+ const text = pickText(raw)
85
+ if (!text) throw new Error('modlens 沒有回傳可用的描述')
86
+ lastError = ''
87
+ return { text, raw }
88
+ } catch (error) {
89
+ lastError = String(error?.message || error).slice(0, 300)
90
+ logger?.warn?.(`modlens 看圖失敗:${lastError}`)
91
+ throw error
92
+ } finally {
93
+ try {
94
+ fs.rmSync(out, { force: true })
95
+ } catch {}
96
+ }
97
+ }
98
+
99
+ return { available, describe, status }
100
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-line-bot",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "把 LINE 官方帳號(Messaging API)接進 DeepSeek Harness 的插件:內建 Cloudflare Tunnel、人設選擇(大肥魚或插件自訂)、主動傳訊與完整設定面板。",
5
5
  "type": "module",
6
6
  "main": "index.js",