dsh-line-bot 0.9.1 → 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,12 @@
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
+
3
10
  ## 0.9.1
4
11
  - 貼吧學習同步的目標下拉現在也包含「插件自訂人設」(`local:`);選自訂人設時會寫入 `<DSH_HOME>/line-bot/persona-overlays/<id>.md` 覆蓋層,每輪注入。
5
12
  - 讀不到大肥魚 HTTP bridge 時,改成直接讀取 `<DSH_HOME>/storages/dsh-persona-dafeiyu/personas.json` 並列出內建人設,不再讓同步目標變空。
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
@@ -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'),
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,6 +27,7 @@ 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
32
  const { createConfigStore, publicConfig, dataDir, dshHome } = configMod
32
33
  const { createPersonaClient } = personaMod
@@ -43,9 +44,10 @@ const { createJokeStore } = jokesMod
43
44
  const { createStickerStore } = stickersMod
44
45
  const { createMemeSource } = memeSourceMod
45
46
  const { createStickerImages } = stickerImageMod
47
+ const { createVision } = visionMod
46
48
  const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody, safeArray, sleep, readJsonSafe } = utilMod
47
49
 
48
- export const VERSION = '0.9.1'
50
+ export const VERSION = '0.9.2'
49
51
 
50
52
  // 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
51
53
  // 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
@@ -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
 
@@ -1877,10 +2035,16 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1877
2035
  const modalities = info?.inputModalities || []
1878
2036
  checks.push(check('model', '回覆模型', 'ok',
1879
2037
  `${selected.provider}/${selected.model}${modalities.length ? `(${modalities.join('+')})` : ''}`,
1880
- modalities.includes('image') ? '' : '這個模型不吃圖;貼圖理解會退回關鍵字,但不影響送表情包'))
2038
+ modalities.includes('image') ? '' : '這個模型不吃圖;會改用 modlens 或另一個視覺模型描述圖片,失敗才退回關鍵字'))
1881
2039
  } catch (error) {
1882
2040
  checks.push(check('model', '回覆模型', 'warn', errorText(error), '在面板「模型與接口來源」重新選模型'))
1883
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
+ }
1884
2048
  if (memeStats.available && base) {
1885
2049
  try {
1886
2050
  const sample = memes.sample({ limit: 1 })[0]
@@ -2036,6 +2200,12 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
2036
2200
  items: stickers.list(),
2037
2201
  warning: publicBaseSync() ? '' : '尚未取得公開網址(Cloudflare tunnel 未啟動)',
2038
2202
  },
2203
+ vision: {
2204
+ ...vision.status(),
2205
+ modelSupportsImages: await modelSupportsImages(),
2206
+ imageVision: cfg.line?.imageVision !== false,
2207
+ stickerVision: cfg.line?.stickerVision !== false,
2208
+ },
2039
2209
  memory: {
2040
2210
  ...memory.stats(),
2041
2211
  enabled: cfg.memory?.enabled !== false,
@@ -2259,6 +2429,47 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
2259
2429
  return maybeAskForTopUp({ dryRun: payload.dryRun !== false, force: payload.force === true })
2260
2430
  case 'meme-stats':
2261
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
+ }
2262
2473
  case 'sticker-look': {
2263
2474
  const stickerId = String(payload.stickerId || '').trim()
2264
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
  }
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.1",
3
+ "version": "0.9.2",
4
4
  "description": "把 LINE 官方帳號(Messaging API)接進 DeepSeek Harness 的插件:內建 Cloudflare Tunnel、人設選擇(大肥魚或插件自訂)、主動傳訊與完整設定面板。",
5
5
  "type": "module",
6
6
  "main": "index.js",