dsh-line-bot 0.3.0 → 0.4.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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1
4
+
5
+ - 角色現在知道「正在用哪個 provider/模型」回答:每輪會帶入目前模型,問它「你是哪個模型」時會照實回答。
6
+ - 面板若把推理強度清空,會送出空字串而不是 `default`。
7
+
8
+ ## 0.4.0
9
+
10
+ - 新增「模型與接口來源」:可選這個 DSH profile 已註冊的 provider 與模型、推理強度;留空則跟隨 DSH 預設。
11
+ - DeepSeek 餘額只適用 `deepseek-account`:切成其他 provider 時,餘額卡會反白停用、不再抓餘額,也不會主動提醒儲值。
12
+ - 面板順序調整:LINE 憑證移到第 2、Cloudflare Tunnel 第 3,模型與來源第 4。
13
+
14
+ ## 0.3.1
15
+
16
+ - 修好 Windows 安裝:`npx dsh-line-bot install` 現在會找到 DeepSeek Harness 內建的 pnpm(`%LOCALAPPDATA%\Programs\…`、`%PROGRAMFILES%`),也會正確透過 `shell` 執行 `dsh.cmd` / `pnpm.cmd`。
17
+ - 找不到 pnpm 時的錯誤訊息補上 Windows 的建議(`npm install -g pnpm` 或改用面板安裝)。
18
+
3
19
  ## 0.3.0
4
20
 
5
21
  - 新增「個人記憶」:每位使用者一份長期記憶(`<DSH_HOME>/line-bot/memories.json`),角色可在對話中自己呼叫 `line_memory_add` 記下重要資訊、用 `line_memory_forget` 忘掉、用 `line_memory_list` 確認。
package/README.en.md CHANGED
@@ -71,11 +71,13 @@ At the top of the panel is a **sticky save bar**: "Save changes / Refresh" stays
71
71
  | Section | Contents |
72
72
  | --- | --- |
73
73
  | Overview | Green-light status for webhook server / tunnel / LINE / persona / proactive messages, public URL, one-click bring-up and start/stop |
74
- | DeepSeek balance | Current balance, per-turn context, low-balance proactive reminder, threshold and cooldown |
75
- | Personal memory | Per-user long-term memory, expiry cleanup, daily self-review, manual delete |
76
74
  | LINE credentials | Channel ID, Channel Secret, Access Token, auto-issue, loading animation, bot info, webhook apply/test, allow-list |
77
75
  | 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 |
76
+ | Model & provider | Pick the provider/model and reasoning effort for LINE replies; disables DeepSeek balance when the provider is not deepseek-account |
78
77
  | 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 |
78
+ | Tieba learning sync | Writes the tone samples learned while browsing Tieba back into the persona's "Tieba tone" section, preview diff, sync now |
79
+ | Personal memory | Per-user long-term memory, expiry cleanup, daily self-review, manual delete |
80
+ | DeepSeek balance | Current balance, per-turn context, low-balance proactive reminder, threshold and cooldown (deepseek-account only) |
79
81
  | Proactive messages | Toggle, probability, interval, daily/monthly caps, minimum gap, quiet hours, idle, target users, prompt, send a test now, statistics |
80
82
  | 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 |
81
83
  | Advanced | Port, Agent provider/model/effort/cwd/timeout, one workspace per user (and its root), welcome/error/empty replies, logs, reload host modules (dev) |
@@ -96,6 +98,15 @@ At the top of the panel is a **sticky save bar**: "Save changes / Refresh" stays
96
98
  4. It takes the last assistant text from the session events and replies with LINE reply; if the reply token has expired it falls back to push.
97
99
  5. The proactive-message scheduler checks once a minute (interval, quiet hours, caps, probability); when conditions are met it asks the Agent to say something on its own and pushes it.
98
100
 
101
+ ## Model & provider
102
+
103
+ The "Model & provider" card chooses the provider/model used for LINE replies:
104
+
105
+ - Providers come from this DSH profile; models are fetched live and the first one is preselected when you pick a provider.
106
+ - "Follow DSH default" leaves the choice to `agentDefaultModel` (the panel shows the effective value).
107
+ - Reasoning effort can stay empty to use the provider default.
108
+ - DeepSeek balance and low-balance reminders only work when the provider is `deepseek-account`; other providers grey out that card, and switching back restores it.
109
+
99
110
  ## Persona sources
100
111
 
101
112
  - **dafeiyu (optional)**: when `dsh-persona-dafeiyu` is installed, the panel lists its personas (built-in + your own); LINE selects them per session, without changing other GUI conversations. It must be in `session` mode; the panel tells you and offers a one-click switch.
package/README.ja.md CHANGED
@@ -71,11 +71,13 @@ dsh plugin --profile desktop add dsh-line-bot
71
71
  | セクション | 内容 |
72
72
  | --- | --- |
73
73
  | 概要 | webhook サーバー/トンネル/LINE/ペルソナ/自発メッセージの状態、公開 URL、ワンクリック起動と停止 |
74
- | DeepSeek 残高 | 現在の残高、毎ターンの注入、残高が少ないときの主動リマインド、しきい値とクールダウン |
75
- | 個人メモリ | ユーザーごとの長期記憶、期限切れの自動削除、毎日の自己整理、手動削除 |
76
74
  | LINE 認証情報 | Channel ID、Channel Secret、Access Token、自動発行、ローディング表示、bot 情報、webhook の適用/テスト、許可リスト |
77
75
  | Cloudflare Tunnel | quick/API トークンモード、トークン、hostname、トンネル名、Account/Zone ID、cloudflared のダウンロード、起動/停止、webhook 自動設定、独自の公開 URL |
76
+ | モデルとプロバイダー | LINE 返信で使う provider/モデルと推論強度を選択。deepseek-account 以外では DeepSeek 残高を自動で無効化 |
78
77
  | 返信ペルソナ | ペルソナ選択(大肥魚またはプラグインのカスタム)、カスタムペルソナの追加・編集・削除、現在のペルソナをカスタムに複製、強制適用、ユーザーによる切り替え許可、大肥魚を session モードに切り替え、Agent 返信のテスト |
78
+ | 貼吧学習の同期 | 貼吧で学んだ口調をペルソナの「貼吧学来的口气」節に書き戻す、差分プレビュー、今すぐ同期 |
79
+ | 個人メモリ | ユーザーごとの長期記憶、期限切れの自動削除、毎日の自己整理、手動削除 |
80
+ | DeepSeek 残高 | 現在の残高、毎ターンの注入、残高が少ないときの主動リマインド、しきい値とクールダウン(deepseek-account のみ) |
79
81
  | 自発メッセージ | オン/オフ、確率、間隔、1 日/1 か月の上限、最短間隔、静かな時間帯、idle、対象ユーザー、プロンプト、今すぐ試送、統計 |
80
82
  | ユーザー | LINE ユーザーごとに対応する DSH セッション、専用ワークスペース、現在のペルソナ(直接変更可)、メッセージ数、最終やりとり、メモリ消去/セッション再作成 |
81
83
  | 詳細設定 | ポート、Agent の provider/model/effort/cwd/タイムアウト、ユーザーごとのワークスペース(ルート含む)、ウェルカム/エラー/空返信、ログ、開発用 host リロード |
@@ -96,6 +98,15 @@ dsh plugin --profile desktop add dsh-line-bot
96
98
  4. セッションイベントから最後の assistant テキストを取り出し、LINE の reply で返信します。reply token が切れていたら自動で push に切り替えます。
97
99
  5. 自発メッセージのスケジューラーが 1 分ごとに確認し(間隔、静かな時間帯、上限、確率)、条件を満たせば Agent に一言話してもらって push します。
98
100
 
101
+ ## モデルとプロバイダー
102
+
103
+ 「モデルとプロバイダー」カードは LINE 返信で使う provider/モデルを決めます:
104
+
105
+ - provider はこの DSH profile に登録されているもの、モデルはライブで取得し、provider を選ぶと最初のモデルが入ります。
106
+ - 「DSH のデフォルトに従う」は `agentDefaultModel` の provider/model をそのまま使います(実際の値はパネルに表示)。
107
+ - 推論強度は空欄なら provider のデフォルト。
108
+ - provider が `deepseek-account` のときだけ DeepSeek 残高と残高リマインドが動きます。他の provider ではカードがグレーアウトして無効になり、戻すと復帰します。
109
+
99
110
  ## ペルソナのソース
100
111
 
101
112
  - **大肥魚(任意)**:`dsh-persona-dafeiyu` がインストールされていると、パネルにそのペルソナ(内蔵+自作)が表示されます。LINE ではセッション単位で指定され、GUI の他の会話には影響しません。`session` モードである必要があり、パネルが案内してワンクリックで切り替えられます。
package/README.ko.md CHANGED
@@ -71,11 +71,13 @@ dsh plugin --profile desktop add dsh-line-bot
71
71
  | 섹션 | 내용 |
72
72
  | --- | --- |
73
73
  | 개요 | webhook 서버/터널/LINE/페르소나/능동 메시지 상태, 공개 URL, 원클릭 기동과 시작/중지 |
74
- | DeepSeek 잔액 | 현재 잔액, 매 턴 주입, 잔액이 적을 때 캐릭터가 먼저 알림, 기준 금액과 쿨다운 |
75
- | 개인 기억 | 사용자별 장기 기억, 만료 자동 삭제, 매일 자기 정리, 수동 삭제 |
76
74
  | LINE 자격 증명 | Channel ID, Channel Secret, Access Token, 자동 발급, 로딩 표시, bot 정보, webhook 적용/테스트, 허용 목록 |
77
75
  | Cloudflare Tunnel | quick/API 토큰 모드, 토큰, hostname, 터널 이름, Account/Zone ID, cloudflared 다운로드, 시작/중지, webhook 자동 설정, 직접 제공하는 공개 URL |
76
+ | 모델과 공급자 | LINE 답장에 쓸 provider/모델과 추론 강도 선택. deepseek-account가 아니면 DeepSeek 잔액 자동 비활성화 |
78
77
  | 답장 페르소나 | 페르소나 선택(大肥魚 또는 플러그인 커스텀), 커스텀 페르소나 추가/편집/삭제, 현재 페르소나를 커스텀으로 복사, 강제 적용, 사용자 직접 전환 허용, 大肥魚를 session 모드로 전환, Agent 답장 테스트 |
78
+ | 貼吧 학습 동기화 | 貼吧에서 배운 말투를 페르소나의 「貼吧学来的口气」 섹션으로 기록, 차이 미리보기, 지금 동기화 |
79
+ | 개인 기억 | 사용자별 장기 기억, 만료 자동 삭제, 매일 자기 정리, 수동 삭제 |
80
+ | DeepSeek 잔액 | 현재 잔액, 매 턴 주입, 잔액이 적을 때 캐릭터가 먼저 알림, 기준 금액과 쿨다운(deepseek-account 전용) |
79
81
  | 능동 메시지 | 켜기/끄기, 확률, 간격, 일일/월간 상한, 최소 간격, 조용한 시간대, idle, 대상 사용자, 프롬프트, 지금 시험 발송, 통계 |
80
82
  | 사용자 | LINE 사용자별 DSH 세션, 전용 작업 폴더, 현재 페르소나(직접 변경 가능), 메시지 수, 마지막 상호작용, 기억 지우기/세션 재설정 |
81
83
  | 고급 설정 | 포트, Agent provider/model/effort/cwd/타임아웃, 사용자별 작업 폴더(루트 포함), 환영/오류/빈 답장, 로그, 개발용 host 리로드 |
@@ -96,6 +98,15 @@ dsh plugin --profile desktop add dsh-line-bot
96
98
  4. 세션 이벤트에서 마지막 assistant 텍스트를 꺼내 LINE reply로 답장합니다. reply token이 만료되었으면 자동으로 push로 전환합니다.
97
99
  5. 능동 메시지 스케줄러가 1분마다 확인하고(간격, 조용한 시간대, 상한, 확률) 조건을 만족하면 Agent에게 한마디 하게 한 뒤 push합니다.
98
100
 
101
+ ## 모델과 공급자
102
+
103
+ 「모델과 공급자」카드는 LINE 답장에 쓸 provider/모델을 정합니다:
104
+
105
+ - provider는 이 DSH profile에 등록된 목록, 모델은 실시간으로 읽어 오며 provider를 고르면 첫 모델이 자동 입력됩니다.
106
+ - 「DSH 기본값 따르기」는 `agentDefaultModel`의 provider/모델을 그대로 씁니다(패널에 실제 값 표시).
107
+ - 추론 강도는 비워 두면 provider 기본값.
108
+ - provider가 `deepseek-account`일 때만 DeepSeek 잔액과 잔액 알림이 동작합니다. 다른 provider에서는 카드가 회색으로 비활성화되고, 돌아오면 복구됩니다.
109
+
99
110
  ## 페르소나 출처
100
111
 
101
112
  - **大肥魚(선택)**: `dsh-persona-dafeiyu`가 설치되어 있으면 패널에 그 페르소나(내장+직접 만든 것)가 나옵니다. LINE에서는 세션 단위로 지정되며 GUI의 다른 대화는 바뀌지 않습니다. `session` 모드여야 하며, 패널이 안내하고 원클릭으로 전환할 수 있습니다.
package/README.md CHANGED
@@ -71,11 +71,13 @@ dsh plugin --profile desktop add dsh-line-bot
71
71
  | 區塊 | 內容 |
72
72
  | --- | --- |
73
73
  | 狀態總覽 | webhook server/隧道/LINE/人設/主動傳訊的綠燈狀態、公開網址、一鍵上線與啟停 |
74
- | DeepSeek 餘額 | 目前餘額、每輪注入、低於門檻時由角色主動提醒、提醒門檻與冷卻 |
75
- | 個人記憶 | 每位使用者分開的長期記憶、到期自動刪、每日請角色整理、手動刪除 |
76
74
  | LINE 憑證 | Channel ID、Channel Secret、Access Token、自動簽發、loading 動畫、bot 資訊、webhook 套用/測試、白名單 |
77
75
  | Cloudflare Tunnel | quick/API Token 模式、Token、hostname、隧道名稱、Account/Zone ID、下載 cloudflared、啟停、自動設定 webhook、自行提供的公開網址 |
76
+ | 模型與接口來源 | 選擇 LINE 回覆的 provider/模型與推理強度;非 deepseek-account 時自動停用 DeepSeek 餘額 |
78
77
  | 回覆人設 | 選擇人設(大肥魚或插件自訂)、新增/編輯/刪除自訂人設、把目前人設複製成自訂、強制套用、允許使用者自己換人設、切換大肥魚 session 模式、測試 Agent 回覆 |
78
+ | 貼吧學習同步 | 把刷貼吧學到的語氣寫回人設的「貼吧學來的口氣」段、預覽差異、立即同步 |
79
+ | 個人記憶 | 每位使用者分開的長期記憶、到期自動刪、每日請角色整理、手動刪除 |
80
+ | DeepSeek 餘額 | 目前餘額、每輪注入、低於門檻時由角色主動提醒、提醒門檻與冷卻(僅 deepseek-account) |
79
81
  | 主動傳訊 | 開關、機率、間隔、每日/每月上限、最短間隔、安靜時段、idle、指定對象、提示詞、立即試傳、統計 |
80
82
  | 使用者 | 每位 LINE 使用者對應的 DSH session、專屬工作資料夾、目前人設(可直接改)、訊息數、上次互動、清空記憶/重設 session |
81
83
  | 進階設定 | 連接埠、Agent provider/model/effort/cwd/逾時、一人一個工作資料夾(含根目錄)、歡迎/錯誤/空回覆、日誌、開發用 host 重新載入 |
@@ -96,6 +98,15 @@ dsh plugin --profile desktop add dsh-line-bot
96
98
  4. 從 session 事件取出最後一段 assistant 文字,用 LINE reply 回覆;reply token 過期就自動改 push。
97
99
  5. 主動傳訊排程器每分鐘檢查一次(間隔、安靜時段、上限、機率),符合條件就請 Agent 主動說一句,再 push 出去。
98
100
 
101
+ ## 模型與接口來源
102
+
103
+ 「模型與接口來源」卡決定 LINE 回覆要用哪個 provider/模型:
104
+
105
+ - 來源清單來自這個 DSH profile 已註冊的 provider;模型清單即時讀取,選 provider 後會自動帶入第一個模型。
106
+ - 「跟隨 DSH 預設」=不覆寫,使用 `agentDefaultModel` 目前的 provider/model(面板會顯示實際值)。
107
+ - 推理強度可留空跟隨 provider 預設。
108
+ - 只有 provider 是 `deepseek-account` 時,DeepSeek 餘額與低餘額提醒才會運作;切到其他來源時該卡會反白停用,切回即恢復。
109
+
99
110
  ## 人設來源
100
111
 
101
112
  - **大肥魚(選用)**:有安裝 `dsh-persona-dafeiyu` 時,面板會列出它的人設(內建+你自訂的),LINE 會逐 session 指定,不會改到 GUI 其他對話。需要它處於 `session` 模式;面板會提示並可一鍵切換。
package/README.zh-CN.md CHANGED
@@ -71,11 +71,13 @@ dsh plugin --profile desktop add dsh-line-bot
71
71
  | 区块 | 内容 |
72
72
  | --- | --- |
73
73
  | 状态总览 | webhook server/隧道/LINE/人设/主动传讯的绿灯状态、公开网址、一键上线与启停 |
74
- | DeepSeek 余额 | 当前余额、每轮注入、低于门槛时由角色主动提醒、提醒门槛与冷却 |
75
- | 个人记忆 | 每位用户分开的长期记忆、到期自动删除、每日请角色整理、手动删除 |
76
74
  | LINE 凭证 | Channel ID、Channel Secret、Access Token、自动签发、loading 动画、bot 信息、webhook 套用/测试、白名单 |
77
75
  | Cloudflare Tunnel | quick/API Token 模式、Token、hostname、隧道名称、Account/Zone ID、下载 cloudflared、启停、自动设置 webhook、自行提供的公开网址 |
76
+ | 模型与接口来源 | 选择 LINE 回复的 provider/模型与推理强度;非 deepseek-account 时自动停用 DeepSeek 余额 |
78
77
  | 回复人设 | 选择人设(大肥鱼或插件自定义)、新增/编辑/删除自定义人设、把目前人设复制成自定义、强制套用、允许用户自己换人设、切换大肥鱼 session 模式、测试 Agent 回复 |
78
+ | 贴吧学习同步 | 把刷贴吧学到的语气写回人设的「贴吧学来的口气」段、预览差异、立即同步 |
79
+ | 个人记忆 | 每位用户分开的长期记忆、到期自动删除、每日请角色整理、手动删除 |
80
+ | DeepSeek 余额 | 当前余额、每轮注入、低于门槛时由角色主动提醒、提醒门槛与冷却(仅 deepseek-account) |
79
81
  | 主动传讯 | 开关、概率、间隔、每日/每月上限、最短间隔、安静时段、idle、指定对象、提示词、立即试传、统计 |
80
82
  | 用户 | 每位 LINE 用户对应的 DSH session、专属工作文件夹、目前人设(可直接改)、消息数、上次互动、清空记忆/重设 session |
81
83
  | 高级设置 | 端口、Agent provider/model/effort/cwd/超时、一人一个工作文件夹(含根目录)、欢迎/错误/空回复、日志、开发用 host 重新载入 |
@@ -96,6 +98,15 @@ dsh plugin --profile desktop add dsh-line-bot
96
98
  4. 从 session 事件取出最后一段 assistant 文字,用 LINE reply 回复;reply token 过期就自动改 push。
97
99
  5. 主动传讯排程器每分钟检查一次(间隔、安静时段、上限、概率),符合条件就请 Agent 主动说一句,再 push 出去。
98
100
 
101
+ ## 模型与接口来源
102
+
103
+ 「模型与接口来源」卡决定 LINE 回复要用哪个 provider/模型:
104
+
105
+ - 来源清单来自这个 DSH profile 已注册的 provider;模型清单即时读取,选 provider 后会自动带入第一个模型。
106
+ - 「跟随 DSH 默认」=不覆写,使用 `agentDefaultModel` 目前的 provider/model(面板会显示实际值)。
107
+ - 推理强度可留空跟随 provider 默认。
108
+ - 只有 provider 是 `deepseek-account` 时,DeepSeek 余额与低余额提醒才会运作;切到其他来源时该卡会反白停用,切回即恢复。
109
+
99
110
  ## 人设来源
100
111
 
101
112
  - **大肥鱼(可选)**:有安装 `dsh-persona-dafeiyu` 时,面板会列出它的人设(内置+你自定义的),LINE 会逐 session 指定,不会改到 GUI 其他对话。需要它处于 `session` 模式;面板会提示并可一键切换。
package/bin/cli.mjs CHANGED
@@ -16,6 +16,8 @@ import fs from 'node:fs'
16
16
  import os from 'node:os'
17
17
  import path from 'node:path'
18
18
  import { spawnSync } from 'node:child_process'
19
+
20
+ const IS_WIN = process.platform === 'win32'
19
21
  import { fileURLToPath } from 'node:url'
20
22
 
21
23
  const PKG = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
@@ -140,19 +142,54 @@ function withBundle(manifest, enabled) {
140
142
  }
141
143
  }
142
144
 
145
+ function windowsPnpmCandidates() {
146
+ const roots = [
147
+ process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Programs'),
148
+ process.env.PROGRAMFILES,
149
+ process.env['PROGRAMFILES(X86)'],
150
+ ].filter(Boolean)
151
+ const suffix = ['resources', 'runtime', 'primary-runtime', 'dependencies', 'pnpm', 'bin', 'pnpm.cjs']
152
+ const found = []
153
+ for (const root of roots) {
154
+ found.push(path.join(root, 'DeepSeek Harness', ...suffix))
155
+ try {
156
+ for (const name of fs.readdirSync(root)) {
157
+ if (/deepseek/i.test(name)) found.push(path.join(root, name, ...suffix))
158
+ }
159
+ } catch {
160
+ /* 目錄不存在就跳過 */
161
+ }
162
+ }
163
+ return found
164
+ }
165
+
143
166
  function findPnpm() {
144
167
  const localPnpm = process.platform === 'darwin'
145
- ? '/Applications/DeepSeek Harness.app/Contents/Resources/runtime/primary-runtime/dependencies/pnpm/bin/pnpm.cjs'
146
- : ''
147
- if (localPnpm && fs.existsSync(localPnpm)) return { kind: 'pnpm', command: process.execPath, prefix: [localPnpm] }
148
- const pnpm = spawnSync('pnpm', ['--version'], { stdio: 'ignore' })
149
- if (pnpm.status === 0) return { kind: 'pnpm', command: 'pnpm', prefix: [] }
168
+ ? ['/Applications/DeepSeek Harness.app/Contents/Resources/runtime/primary-runtime/dependencies/pnpm/bin/pnpm.cjs']
169
+ : process.platform === 'win32'
170
+ ? windowsPnpmCandidates()
171
+ : []
172
+ for (const candidate of localPnpm) {
173
+ if (fs.existsSync(candidate)) return { kind: 'pnpm', command: process.execPath, prefix: [candidate], shell: false }
174
+ }
175
+ const pnpm = spawnSync('pnpm', ['--version'], { stdio: 'ignore', shell: IS_WIN })
176
+ if (pnpm.status === 0) return { kind: 'pnpm', command: 'pnpm', prefix: [], shell: IS_WIN }
150
177
  return null
151
178
  }
152
179
 
153
180
  function findDsh() {
154
- const probe = spawnSync('dsh', ['--version'], { stdio: 'ignore' })
155
- return probe.status === 0 ? 'dsh' : ''
181
+ const probe = spawnSync('dsh', ['--version'], { stdio: 'ignore', shell: IS_WIN })
182
+ return probe.status === 0 ? { command: 'dsh', shell: IS_WIN } : null
183
+ }
184
+
185
+ function quoteWin(value) {
186
+ return /[\s"]/.test(value) ? '"' + value.replace(/"/g, '\\"') + '"' : value
187
+ }
188
+
189
+ function spawnTool(tool, args, options) {
190
+ if (!tool.shell) return spawnSync(tool.command, [...(tool.prefix || []), ...args], options)
191
+ const line = [tool.command, ...args].map(quoteWin).join(' ')
192
+ return spawnSync(line, { ...options, shell: true })
156
193
  }
157
194
 
158
195
  function runInstall(profile, versionSpec, { dryRun }) {
@@ -161,18 +198,20 @@ function runInstall(profile, versionSpec, { dryRun }) {
161
198
  let result
162
199
  if (dsh) {
163
200
  const args = ['plugin', '--profile', profile, 'add', versionSpec]
164
- out(`→ ${dsh} ${args.join(' ')}`)
165
- if (!dryRun) result = spawnSync(dsh, args, { cwd: dir, stdio: 'inherit' })
201
+ out(`→ dsh ${args.join(' ')}`)
202
+ if (!dryRun) result = spawnTool(dsh, args, { cwd: dir, stdio: 'inherit' })
166
203
  } else {
167
204
  const pnpm = findPnpm()
168
205
  if (!pnpm) {
169
206
  fail(
170
- '找不到 dsh 或 pnpm。請先安裝 DeepSeek Harness 桌面版(內含 pnpm),或改用面板「設定 → 外掛 → 安裝」。',
207
+ IS_WIN
208
+ ? '找不到 dsh 或 pnpm。請先安裝 DeepSeek Harness 桌面版(內含 pnpm),或先執行「npm install -g pnpm」再重試;也可以直接用面板「設定 → 外掛 → 安裝 bundle」。'
209
+ : '找不到 dsh 或 pnpm。請先安裝 DeepSeek Harness 桌面版(內含 pnpm),或改用面板「設定 → 外掛 → 安裝」。',
171
210
  )
172
211
  }
173
- const args = [...pnpm.prefix, 'add', versionSpec, '--dir', dir]
212
+ const args = [...(pnpm.prefix || []), 'add', versionSpec, '--dir', dir]
174
213
  out(`→ ${pnpm.command} ${args.join(' ')}`)
175
- if (!dryRun) result = spawnSync(pnpm.command, args, { cwd: dir, stdio: 'inherit' })
214
+ if (!dryRun) result = spawnTool(pnpm, args, { cwd: dir, stdio: 'inherit' })
176
215
  }
177
216
  if (!dryRun && result && result.status !== 0) {
178
217
  fail(`安裝套件失敗(pnpm 結束碼 ${result.status})。請確認網路與 ${dir} 的權限後重試。`)
@@ -232,9 +271,9 @@ function commandUninstall(options) {
232
271
  const dsh = findDsh()
233
272
  if (dsh) {
234
273
  const args = ['plugin', '--profile', profile, 'remove', NAME]
235
- out(`→ ${dsh} ${args.join(' ')}`)
274
+ out(`→ dsh ${args.join(' ')}`)
236
275
  if (!options.dryRun) {
237
- const result = spawnSync(dsh, args, { cwd: dir, stdio: 'inherit' })
276
+ const result = spawnTool(dsh, args, { cwd: dir, stdio: 'inherit' })
238
277
  if (result.status !== 0) out('(pnpm 移除失敗,可能本來就沒安裝;bundle 清單已處理)')
239
278
  }
240
279
  } else {
package/client.js CHANGED
@@ -13,12 +13,13 @@ window.__ModuleLoader__.load({
13
13
  // 二級選單:卡片區塊的快速跳轉。
14
14
  const NAV = [
15
15
  { key: 'overview', label: '狀態總覽' },
16
- { key: 'balance', label: 'DeepSeek 餘額' },
17
- { key: 'memory', label: '個人記憶' },
18
16
  { key: 'line', label: 'LINE 憑證' },
19
17
  { key: 'tunnel', label: 'Cloudflare' },
18
+ { key: 'model', label: '模型與來源' },
20
19
  { key: 'persona', label: '回覆人設' },
21
20
  { key: 'sync', label: '貼吧同步' },
21
+ { key: 'memory', label: '個人記憶' },
22
+ { key: 'balance', label: 'DeepSeek 餘額' },
22
23
  { key: 'nudge', label: '主動傳訊' },
23
24
  { key: 'users', label: '使用者' },
24
25
  { key: 'advanced', label: '進階/日誌' },
@@ -187,10 +188,10 @@ window.__ModuleLoader__.load({
187
188
  return h('button', { type: 'button', style, onClick: disabled ? undefined : onClick, disabled: !!disabled, title: title || '' }, children)
188
189
  }
189
190
 
190
- function Card({ id, title, desc, right, children }) {
191
+ function Card({ id, title, desc, right, children, order }) {
191
192
  return h(
192
193
  'section',
193
- { id, 'data-section': id || undefined, style: { ...S.card, scrollMarginTop: 104 } },
194
+ { id, 'data-section': id || undefined, style: { ...S.card, scrollMarginTop: 104, ...(order ? { order } : {}) } },
194
195
  h('div', { style: S.cardHead }, h('div', null, h('div', { style: S.cardTitle }, title), desc ? h('div', { style: S.cardDesc }, desc) : null), right || null),
195
196
  h('div', { style: S.cardBody }, children),
196
197
  )
@@ -251,6 +252,28 @@ window.__ModuleLoader__.load({
251
252
  )
252
253
  }
253
254
 
255
+
256
+ function UserChips({ users, value, onChange, emptyText }) {
257
+ const ids = Array.isArray(value) ? value : []
258
+ const list = Array.isArray(users) ? users : []
259
+ if (list.length === 0) {
260
+ return h('div', { style: S.note }, emptyText || '目前還沒有使用者跟 bot 聊過;有人傳訊息後就會出現在這裡。')
261
+ }
262
+ return h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 6 } },
263
+ list.map((user) => {
264
+ const selected = ids.includes(user.userId)
265
+ const label = (user.displayName || '未命名') + '(' + String(user.userId).slice(-6) + ')'
266
+ return h('button', {
267
+ key: user.userId,
268
+ type: 'button',
269
+ onClick: () => onChange(selected ? ids.filter((id) => id !== user.userId) : [...ids, user.userId]),
270
+ title: user.userId,
271
+ style: { ...S.btn, padding: '4px 10px', borderRadius: 999, fontSize: 12, background: selected ? 'var(--dsw-alias-state-business-primary, #4b7bec)' : 'transparent', color: selected ? '#fff' : 'var(--dsw-alias-label-primary)', border: '1px solid ' + (selected ? 'transparent' : 'var(--dsw-alias-border-l2)') },
272
+ }, label)
273
+ }),
274
+ )
275
+ }
276
+
254
277
  function Badge({ ok, children }) {
255
278
  const color = ok === true ? S.ok.color : ok === false ? S.bad.color : S.warn.color
256
279
  const dot = ok === true ? S.ok.color : ok === false ? S.bad.color : S.warn.color
@@ -293,6 +316,7 @@ window.__ModuleLoader__.load({
293
316
  'persona-status': '重新讀取人設',
294
317
  'persona-mode': '切換人設模式',
295
318
  'tieba-preview': '預覽貼吧同步差異',
319
+ 'model-catalog': '讀取模型清單',
296
320
  'balance-refresh': '查詢 DeepSeek 餘額',
297
321
  'memory-list': '讀取個人記憶',
298
322
  'memory-delete': '刪除記憶',
@@ -323,6 +347,7 @@ window.__ModuleLoader__.load({
323
347
  const [syncPreview, setSyncPreview] = useState(null)
324
348
  const [balancePreview, setBalancePreview] = useState(null)
325
349
  const [memoryData, setMemoryData] = useState(null)
350
+ const [modelCatalog, setModelCatalog] = useState(null)
326
351
  const [localEdit, setLocalEdit] = useState({ id: '', name: '', text: '' })
327
352
 
328
353
  const dirty = useMemo(() => {
@@ -481,8 +506,26 @@ window.__ModuleLoader__.load({
481
506
  await loadMemory({ quiet: true })
482
507
  }
483
508
 
509
+ async function loadModelCatalog({ quiet = false, force = false } = {}) {
510
+ if (!quiet) setBusy('model-catalog')
511
+ try {
512
+ if (dirty && !quiet) await persist(false)
513
+ const value = await jpost('/action', { action: 'model-catalog', payload: force ? { force: true } : {} })
514
+ if (value?.status) {
515
+ setStatus(value.status)
516
+ setDraft(stripSecrets(value.status.config))
517
+ }
518
+ setModelCatalog(value?.result || null)
519
+ } catch (err) {
520
+ if (!quiet) setToast({ kind: 'error', text: '讀取模型清單失敗:' + (err.message || err) })
521
+ } finally {
522
+ if (!quiet) setBusy('')
523
+ }
524
+ }
525
+
484
526
  useEffect(() => {
485
527
  loadMemory({ quiet: true })
528
+ loadModelCatalog({ quiet: true })
486
529
  // eslint-disable-next-line react-hooks/exhaustive-deps
487
530
  }, [])
488
531
 
@@ -618,6 +661,8 @@ window.__ModuleLoader__.load({
618
661
  const balanceTotal = balanceStatus.cny ? balanceStatus.cny.total : balanceStatus.usd ? balanceStatus.usd.total : null
619
662
  const memoryCfg = cfg.memory || {}
620
663
  const memoryStatus = status.memory || {}
664
+ const agentStatus = status.agent || {}
665
+ const modelProviders = Array.isArray(modelCatalog?.providers) ? modelCatalog.providers : []
621
666
  const memoryUsers = memoryData?.users || []
622
667
  const syncStats = status.sync || {}
623
668
  const runtime = status
@@ -678,7 +723,7 @@ window.__ModuleLoader__.load({
678
723
  // 1. 狀態總覽
679
724
  h(
680
725
  Card,
681
- { id: sectionDomId('overview'), title: '狀態總覽', desc: '綠燈=已就緒。第一次使用建議依序:填入 LINE 憑證 → 啟動 webhook → 啟動隧道。', right: h(Btn, { tone: 'primary', onClick: () => act('setup'), disabled: !!busy }, busy === 'setup' ? '上線中…' : '一鍵上線') },
726
+ { id: sectionDomId('overview'), order: 1, title: '狀態總覽', desc: '綠燈=已就緒。第一次使用建議依序:填入 LINE 憑證 → 啟動 webhook → 啟動隧道。', right: h(Btn, { tone: 'primary', onClick: () => act('setup'), disabled: !!busy }, busy === 'setup' ? '上線中…' : '一鍵上線') },
682
727
  h(
683
728
  'div',
684
729
  { style: S.row },
@@ -705,11 +750,20 @@ window.__ModuleLoader__.load({
705
750
  h(
706
751
  Card,
707
752
  {
708
- id: sectionDomId('balance'),
753
+ id: sectionDomId('balance'), order: 8,
709
754
  title: 'DeepSeek 餘額',
710
- desc: '讓角色知道 API 錢包剩多少;低於門檻時由角色自己想一句話提醒你儲值(會避開主動傳訊的靜音時段)。',
711
- right: h(Btn, { onClick: () => act('balance-refresh'), disabled: !!busy }, busy === 'balance-refresh' ? '查詢中…' : '重新查詢'),
755
+ desc: '讓角色知道 API 錢包剩多少;低於門檻時由角色自己想一句話提醒你儲值(會避開主動傳訊的靜音時段)。只有模型來源是 deepseek-account 時才適用。',
756
+ right: h(Btn, { onClick: () => act('balance-refresh'), disabled: !!busy || balanceStatus.applicable === false }, busy === 'balance-refresh' ? '查詢中…' : '重新查詢'),
712
757
  },
758
+ balanceStatus.applicable === false
759
+ ? h('div', { style: S.warnBox }, `目前模型來源是「${agentStatus.provider || '未設定'}」,不是 DeepSeek 官方帳號,所以餘額查詢與主動提醒已停用。把上面的「模型與接口來源」切回 deepseek-account 就會自動恢復。`)
760
+ : null,
761
+ h(
762
+ 'fieldset',
763
+ {
764
+ disabled: balanceStatus.applicable === false,
765
+ style: { border: 0, margin: 0, padding: 0, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 12, ...(balanceStatus.applicable === false ? { opacity: 0.45 } : {}) },
766
+ },
713
767
  h(
714
768
  'div',
715
769
  { style: S.row },
@@ -737,6 +791,7 @@ window.__ModuleLoader__.load({
737
791
  ? ('查詢失敗:' + balanceStatus.error)
738
792
  : '按「重新查詢」讀取目前的 DeepSeek API 餘額。',
739
793
  ),
794
+ h(Field, { label: '餘額提醒對象', hint: '留空=沿用主動傳訊的指定對象;只會提醒這裡選的人' }, h(UserChips, { users: status.users, value: balanceCfg.targetUserIds, onChange: (ids) => update({ balance: { targetUserIds: ids } }) })),
740
795
  h(
741
796
  'div',
742
797
  { style: S.grid },
@@ -756,13 +811,14 @@ window.__ModuleLoader__.load({
756
811
  h(Btn, { tone: 'primary', onClick: askBalance, disabled: !!busy }, '立即提醒一次'),
757
812
  ),
758
813
  balancePreview && balancePreview.text ? h('div', { style: S.panelBox }, h('div', { style: S.fieldTitle }, '角色會傳這則:'), h('pre', { style: S.pre }, balancePreview.text)) : null,
814
+ ),
759
815
  ),
760
816
 
761
817
  // 1.6 個人記憶
762
818
  h(
763
819
  Card,
764
820
  {
765
- id: sectionDomId('memory'),
821
+ id: sectionDomId('memory'), order: 7,
766
822
  title: '個人記憶',
767
823
  desc: '每位使用者分開。角色覺得重要的事會自己記下來;有期限的到期自動刪,每天也會請角色自己整理一次。',
768
824
  right: h(Btn, { onClick: () => loadMemory(), disabled: !!busy }, busy === 'memory-list' ? '讀取中…' : '重新整理'),
@@ -808,7 +864,7 @@ window.__ModuleLoader__.load({
808
864
  // 2. LINE 憑證
809
865
  h(
810
866
  Card,
811
- { id: sectionDomId('line'), title: 'LINE 憑證', desc: '在 LINE Developers Console 的 Messaging API channel 取得。貼上後可按「驗證」確認。' },
867
+ { id: sectionDomId('line'), order: 2, title: 'LINE 憑證', desc: '在 LINE Developers Console 的 Messaging API channel 取得。貼上後可按「驗證」確認。' },
812
868
  h(
813
869
  'div',
814
870
  { style: S.note },
@@ -854,7 +910,7 @@ window.__ModuleLoader__.load({
854
910
  // 3. Cloudflare Tunnel
855
911
  h(
856
912
  Card,
857
- { id: sectionDomId('tunnel'), title: 'Cloudflare Tunnel', desc: '把 127.0.0.1 的 webhook 公開到 https,LINE 才收得到。Quick tunnel 免 token、網址每次不同;API Token 模式可固定網域。' },
913
+ { id: sectionDomId('tunnel'), order: 3, title: 'Cloudflare Tunnel', desc: '把 127.0.0.1 的 webhook 公開到 https,LINE 才收得到。Quick tunnel 免 token、網址每次不同;API Token 模式可固定網域。' },
858
914
  h('div', { style: S.grid },
859
915
  h(Field, { label: '模式' },
860
916
  h('select', {
@@ -892,10 +948,70 @@ window.__ModuleLoader__.load({
892
948
  h('div', { style: S.hint }, 'Quick tunnel 每次重啟網址都會變;若 LINE 的 webhook 沒自動更新,按上面「把隧道網址套到 webhook」。API Token 模式需要先在 Cloudflare 建立 API Token(權限:Account → Cloudflare Tunnel → Edit;Zone → DNS → Edit)。若驗證後面板沒自動帶入帳號/Zone,代表 token 少了 Account Settings:Read/Zone:Read,請手動填 Account ID 與 Zone ID(或幫 token 補上讀取權限)。'),
893
949
  ),
894
950
 
951
+ // 3.5 模型與接口來源
952
+ h(
953
+ Card,
954
+ {
955
+ id: sectionDomId('model'),
956
+ order: 4,
957
+ title: '模型與接口來源',
958
+ desc: 'LINE 回覆要用的 provider/模型。留空=跟隨 DSH 目前的預設;選非 DeepSeek 的來源時,下面的餘額功能會自動停用。',
959
+ right: h(Btn, { onClick: () => loadModelCatalog({ force: true }), disabled: !!busy }, busy === 'model-catalog' ? '讀取中…' : '重新讀取清單'),
960
+ },
961
+ h(KV, {
962
+ rows: [
963
+ ['目前使用', `${agentStatus.provider || '—'} / ${agentStatus.model || '—'}${agentStatus.source === 'custom' ? '(面板指定)' : '(跟隨 DSH 預設)'}`],
964
+ ['DSH 預設', `${agentStatus.defaultProvider || '—'} / ${agentStatus.defaultModel || '—'}`],
965
+ ['餘額功能', agentStatus.provider === 'deepseek-account' ? '可用(DeepSeek 官方帳號)' : '已停用(不是 deepseek-account)'],
966
+ ],
967
+ }),
968
+ h('div', { style: S.grid },
969
+ h(Field, { label: '模型接口來源(provider)', hint: '清單來自這個 DSH profile 已註冊的 provider' },
970
+ h('select', {
971
+ style: S.input,
972
+ value: draft.agent.provider || '',
973
+ onChange: (event) => {
974
+ const provider = event.target.value
975
+ const entry = modelProviders.find((item) => item.id === provider)
976
+ const first = entry?.models?.[0]?.id || ''
977
+ update({ agent: { provider, model: provider ? first : '' } })
978
+ },
979
+ },
980
+ h('option', { value: '' }, `跟隨 DSH 預設${agentStatus.defaultProvider ? `(${agentStatus.defaultProvider} / ${agentStatus.defaultModel})` : ''}`),
981
+ modelProviders.map((item) => h('option', { key: item.id, value: item.id }, `${item.name}(${item.id})${item.models?.length ? '' : ' · 無模型資料'}`)),
982
+ draft.agent.provider && !modelProviders.some((item) => item.id === draft.agent.provider)
983
+ ? h('option', { value: draft.agent.provider }, draft.agent.provider)
984
+ : null,
985
+ )),
986
+ h(Field, { label: '模型', hint: '可直接輸入模型 id,或從清單選' },
987
+ h('input', {
988
+ style: S.input,
989
+ list: 'line-bot-model-options',
990
+ value: draft.agent.model || '',
991
+ placeholder: '留空=跟隨預設;例:deepseek-flash',
992
+ onChange: (event) => update({ agent: { model: event.target.value } }),
993
+ }),
994
+ h('datalist', { id: 'line-bot-model-options' },
995
+ ((modelProviders.find((item) => item.id === (draft.agent.provider || agentStatus.defaultProvider)) || {}).models || []).map((item) => h('option', { key: item.id, value: item.id }, item.name))),
996
+ ),
997
+ h(Field, { label: '推理強度(選填)', hint: '留空=跟隨 provider 預設' },
998
+ h(Text, { value: draft.agent.reasoningEffort || '', onChange: (value) => update({ agent: { reasoningEffort: value } }), placeholder: 'max / high / medium' })),
999
+ ),
1000
+ h('div', { style: S.note }, modelCatalog?.error
1001
+ ? `讀取模型清單失敗:${modelCatalog.error}`
1002
+ : modelCatalog
1003
+ ? `共 ${modelProviders.length} 個來源。清單只列已註冊的 provider;實際能不能用仍取決於該來源的憑證。`
1004
+ : '正在讀取模型清單…'),
1005
+ h('div', { style: S.row },
1006
+ h(Btn, { onClick: () => loadModelCatalog({ force: true }), disabled: !!busy }, busy === 'model-catalog' ? '讀取中…' : '重新讀取模型清單'),
1007
+ h(Btn, { onClick: () => act('agent-test', { text: '請用一句話確認你現在用的模型來源。' }), disabled: !!busy }, busy === 'agent-test' ? '測試中…' : '測試 Agent 回覆'),
1008
+ ),
1009
+ ),
1010
+
895
1011
  // 4. 人設
896
1012
  h(
897
1013
  Card,
898
- { id: sectionDomId('persona'), title: '回覆人設', desc: 'LINE 的回覆會套用這裡選的人設。來源有兩種:大肥魚插件(有安裝時)、插件自己的自訂人設(不依賴任何外掛)。' },
1014
+ { id: sectionDomId('persona'), order: 5, title: '回覆人設', desc: 'LINE 的回覆會套用這裡選的人設。來源有兩種:大肥魚插件(有安裝時)、插件自己的自訂人設(不依賴任何外掛)。' },
899
1015
  persona.warning ? h('div', { style: S.warnBox }, persona.warning) : null,
900
1016
  persona.error ? h('div', { style: S.warnBox }, `讀不到大肥魚插件:${persona.error}`) : null,
901
1017
  h('div', { style: S.grid },
@@ -956,7 +1072,7 @@ window.__ModuleLoader__.load({
956
1072
  // 4b. 貼吧學習同步
957
1073
  h(
958
1074
  Card,
959
- { id: sectionDomId('sync'), title: '貼吧學習同步', desc: '把「刷貼吧」每天演化學到的語氣樣本,寫回上方人設的「## 貼吧學來的口氣」段落,並重載大肥魚插件讓它立即生效。' },
1075
+ { id: sectionDomId('sync'), order: 6, title: '貼吧學習同步', desc: '把「刷貼吧」每天演化學到的語氣樣本,寫回上方人設的「## 貼吧學來的口氣」段落,並重載大肥魚插件讓它立即生效。' },
960
1076
  h(Toggle, { checked: syncCfg.enabled !== false, onChange: (value) => update({ sync: { enabled: value } }), label: '開啟自動同步', hint: '每小時檢查一次;貼吧人設版本有變才寫入(寫入前會備份 personas.json)' }),
961
1077
  h('div', { style: S.grid },
962
1078
  h(Field, { label: '同步到哪個人設' },
@@ -986,7 +1102,7 @@ window.__ModuleLoader__.load({
986
1102
  // 5. 主動傳訊
987
1103
  h(
988
1104
  Card,
989
- { id: sectionDomId('nudge'), title: '主動傳訊(大肥魚自己找人聊天)', desc: '由插件內建排程器執行:每隔幾分鐘擲骰子,符合條件就請 Agent 主動傳一句;不適合時 Agent 可回 SKIP。' },
1105
+ { id: sectionDomId('nudge'), order: 9, title: '主動傳訊(大肥魚自己找人聊天)', desc: '由插件內建排程器執行:每隔幾分鐘擲骰子,符合條件就請 Agent 主動傳一句;不適合時 Agent 可回 SKIP。' },
990
1106
  h(Toggle, { checked: !!nudgeCfg.enabled, onChange: (value) => update({ nudge: { enabled: value } }), label: '開啟主動傳訊' }),
991
1107
  h('div', { style: S.grid },
992
1108
  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 } }) })),
@@ -998,7 +1114,7 @@ window.__ModuleLoader__.load({
998
1114
  h(Field, { label: '安靜開始(HH:MM)' }, h(Text, { value: nudgeCfg.quietStart, onChange: (value) => update({ nudge: { quietStart: value } }), placeholder: '00:30' })),
999
1115
  h(Field, { label: '安靜結束(HH:MM)' }, h(Text, { value: nudgeCfg.quietEnd, onChange: (value) => update({ nudge: { quietEnd: value } }), placeholder: '09:30' })),
1000
1116
  ),
1001
- h(Field, { label: '指定對象(LINE userId,一行一個)', hint: '留空=所有已聊過的使用者中挑最久沒互動的' }, h(Area, { value: textList(nudgeCfg.targetUserIds), onChange: (value) => update({ nudge: { targetUserIds: parseList(value) } }), rows: 2, mono: true })),
1117
+ h(Field, { label: '指定對象', hint: '留空=所有已聊過的使用者中挑最久沒互動的;直接點選要傳給誰' }, h(UserChips, { users: status.users, value: nudgeCfg.targetUserIds, onChange: (ids) => update({ nudge: { targetUserIds: ids } }) })),
1002
1118
  h(Field, { label: '主動傳訊提示詞(給 Agent 的指示)', hint: '留空=用內建預設(自然、簡短、不提系統、不重複、不適合就 SKIP)' }, h(Area, { value: nudgeCfg.prompt, onChange: (value) => update({ nudge: { prompt: value } }), rows: 4 })),
1003
1119
  h('div', { style: S.row },
1004
1120
  h(Btn, { onClick: () => act('nudge-test', { force: true }), disabled: !!busy, tone: 'primary' }, busy === 'nudge-test' ? '試跑中…' : '立即試傳一則'),
@@ -1019,7 +1135,7 @@ window.__ModuleLoader__.load({
1019
1135
  // 6. 使用者
1020
1136
  h(
1021
1137
  Card,
1022
- { id: sectionDomId('users'), title: '使用者', desc: '與 bot 互動過的 LINE 使用者。每個人有自己的 DSH session 與工作資料夾(記憶、檔案不互通),人設也可以個別指定。' },
1138
+ { id: sectionDomId('users'), order: 10, title: '使用者', desc: '與 bot 互動過的 LINE 使用者。每個人有自己的 DSH session 與工作資料夾(記憶、檔案不互通),人設也可以個別指定。' },
1023
1139
  (status.users || []).length === 0
1024
1140
  ? h('div', { style: S.hint }, '還沒有人傳訊息給 bot。')
1025
1141
  : h('table', { style: S.table },
@@ -1055,7 +1171,7 @@ window.__ModuleLoader__.load({
1055
1171
  // 7. 進階設定
1056
1172
  h(
1057
1173
  Card,
1058
- { id: sectionDomId('advanced'), title: '進階設定', desc: '通常不用改;出問題時可從這裡調整。' },
1174
+ { id: sectionDomId('advanced'), order: 11, title: '進階設定', desc: '通常不用改;出問題時可從這裡調整。' },
1059
1175
  h('div', { style: S.grid },
1060
1176
  h(Field, { label: 'Webhook 連接埠', hint: '改完按儲存;伺服器會自動重啟。' }, h(Num, { value: server.port, min: 1024, max: 65535, onChange: (value) => update({ server: { port: value } }) })),
1061
1177
  h(Field, { label: '每位使用者獨立工作資料夾', hint: '開啟=每個 LINE 使用者有自己的 cwd(檔案、AGENTS.md、工作區記憶互不相通)' },
package/lib/config.mjs CHANGED
@@ -99,6 +99,7 @@ export const DEFAULT_CONFIG = {
99
99
  threshold: 10,
100
100
  ask: true,
101
101
  cooldownHours: 12,
102
+ targetUserIds: [],
102
103
  },
103
104
  memory: {
104
105
  // 每位使用者各自的個人記憶:agent 覺得重要就自己記,有期限的到期自動刪。
@@ -221,6 +222,7 @@ export function sanitizePatch(current, patch) {
221
222
  balance.ask = Boolean(balance.ask)
222
223
  balance.threshold = clampFloat(balance.threshold, 0, 100000, 10)
223
224
  balance.cooldownHours = clampInt(balance.cooldownHours, 1, 168, 12)
225
+ balance.targetUserIds = safeArray(balance.targetUserIds)
224
226
 
225
227
  const memory = next.memory || (next.memory = {})
226
228
  memory.enabled = Boolean(memory.enabled)
package/lib/host.mjs CHANGED
@@ -32,9 +32,9 @@ const { createLocalPersonas, LOCAL_PREFIX } = localPersonasMod
32
32
  const { buildTiebaContext } = tiebaContextMod
33
33
  const { createBalanceMonitor } = balanceMod
34
34
  const { createMemoryStore } = memoryMod
35
- const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody } = utilMod
35
+ const { chunkText, clip, inQuietWindow, jsonFail, jsonOk, maskSecret, readJsonBody, safeArray } = utilMod
36
36
 
37
- export const VERSION = '0.3.0'
37
+ export const VERSION = '0.4.1'
38
38
 
39
39
  // 環境變數提供的 Cloudflare API Token(方便用 shell 注入、不用把 token 寫進設定檔)。
40
40
  // 設定檔裡的 token 優先;兩者都沒有時才用環境變數。
@@ -170,6 +170,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
170
170
  // ── DeepSeek 餘額 ───────────────────────────────────────────────────────
171
171
  const balance = createBalanceMonitor({ ctx, logger })
172
172
  let balanceTimer = null
173
+ let balanceNextAt = 0
173
174
  const memory = createMemoryStore({ dir, logger, getConfig: () => store.get() })
174
175
  let memoryTimer = null
175
176
 
@@ -181,9 +182,64 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
181
182
  return null
182
183
  }
183
184
 
185
+ function modelSelection() {
186
+ const cfg = store.get()
187
+ let fallback = null
188
+ try {
189
+ fallback = ctx.get?.('agentDefaultModel')?.currentSelection?.() || null
190
+ } catch {
191
+ fallback = null
192
+ }
193
+ const custom = Boolean(String(cfg.agent.provider || '').trim() || String(cfg.agent.model || '').trim())
194
+ const provider = String(cfg.agent.provider || fallback?.provider || '').trim()
195
+ const model = String(cfg.agent.model || fallback?.model || '').trim()
196
+ return {
197
+ provider,
198
+ model,
199
+ source: custom ? 'custom' : 'default',
200
+ defaultProvider: fallback?.provider || '',
201
+ defaultModel: fallback?.model || '',
202
+ }
203
+ }
204
+
205
+ // 只有 DeepSeek 官方帳號(deepseek-account)才查得到餘額;其他來源停用餘額相關功能。
206
+ function balanceApplicable() {
207
+ return modelSelection().provider === 'deepseek-account'
208
+ }
209
+
210
+ let modelCache = null
211
+ async function modelCatalog({ force = false } = {}) {
212
+ const now = Date.now()
213
+ if (!force && modelCache && now - modelCache.at < 60_000) return modelCache.value
214
+ const llm = ctx.get?.('llm')
215
+ if (!llm || typeof llm.listProviders !== 'function') {
216
+ return { providers: [], current: modelSelection(), error: '這個 profile 沒有 llm 服務,無法列出模型' }
217
+ }
218
+ const providers = []
219
+ try {
220
+ const infos = await llm.listProviders()
221
+ for (const info of infos || []) {
222
+ const entry = { id: info.id, name: info.name || info.id, models: [] }
223
+ try {
224
+ const models = await llm.listModels(info.id)
225
+ entry.models = (models || []).map((item) => ({ id: item.id, name: item.name || item.id }))
226
+ } catch (error) {
227
+ entry.error = errorText(error)
228
+ }
229
+ providers.push(entry)
230
+ }
231
+ } catch (error) {
232
+ return { providers: [], current: modelSelection(), error: errorText(error) }
233
+ }
234
+ const value = { providers, current: modelSelection(), at: now }
235
+ modelCache = { at: now, value }
236
+ return value
237
+ }
238
+
184
239
  function balanceContextText() {
185
240
  const cfg = store.get()
186
241
  if (cfg.balance.enabled === false) return ''
242
+ if (!balanceApplicable()) return ''
187
243
  const view = balanceView()
188
244
  if (!view) return ''
189
245
  const cached = balance.get()
@@ -197,6 +253,21 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
197
253
  return lines.join('\n')
198
254
  }
199
255
 
256
+ function modelContextText() {
257
+ const sel = modelSelection()
258
+ if (!sel.provider || !sel.model) return ''
259
+ const value = modelCache?.value
260
+ const providerInfo = (value?.providers || []).find((item) => item.id === sel.provider)
261
+ const modelInfo = (providerInfo?.models || []).find((item) => item.id === sel.model)
262
+ const providerName = providerInfo?.name || sel.provider
263
+ const modelName = modelInfo?.name || sel.model
264
+ const effort = String(store.get().agent?.reasoningEffort || '').trim()
265
+ return [
266
+ '【目前使用的模型】' + sel.provider + (providerName !== sel.provider ? '(' + providerName + ')' : '') + '/' + sel.model + (modelName !== sel.model ? '(' + modelName + ')' : '') + (effort ? ',推理強度 ' + effort : '') + '。',
267
+ '如果主人問你「你是哪個模型」、「用什麼模型回答」,就照這一行回答模型與來源;只講這兩個,不要加推測或額外解釋,也不要說成別的模型,不用提這是插件提供的背景資訊。',
268
+ ].join('\n')
269
+ }
270
+
200
271
  function memoryRulesText() {
201
272
  if (store.get().memory?.enabled === false) return ''
202
273
  return [
@@ -228,7 +299,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
228
299
  }
229
300
 
230
301
  function agentContextText(userId) {
231
- return [tiebaContextText(), balanceContextText(), memoryContextText(userId)].filter(Boolean).join('\n\n')
302
+ return [modelContextText(), tiebaContextText(), balanceContextText(), memoryContextText(userId)].filter(Boolean).join('\n\n')
232
303
  }
233
304
 
234
305
  async function refreshBalance() {
@@ -238,6 +309,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
238
309
  async function maybeAskForTopUp({ dryRun = true, force = false } = {}) {
239
310
  const cfg = store.get()
240
311
  if (cfg.balance.enabled === false || cfg.balance.ask === false) return { skipped: 'disabled' }
312
+ if (!balanceApplicable()) return { skipped: 'provider-not-deepseek', provider: modelSelection().provider }
241
313
  const view = balanceView()
242
314
  if (!view) {
243
315
  const cached = balance.get()
@@ -253,7 +325,8 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
253
325
  if (!force && lastAskAt && Date.now() - lastAskAt < cooldownMs) return { skipped: 'cooldown', lastAskAt }
254
326
  if (!force && inQuietWindow(Date.now(), cfg.nudge.quietStart, cfg.nudge.quietEnd)) return { skipped: 'quiet-hours' }
255
327
  const candidates = Object.entries(state.users || {}).filter(([, user]) => user?.sessionId)
256
- const preferred = (cfg.nudge.targetUserIds || []).find((id) => candidates.some(([key]) => key === id))
328
+ const targetIds = safeArray(cfg.balance?.targetUserIds).length ? safeArray(cfg.balance?.targetUserIds) : safeArray(cfg.nudge?.targetUserIds)
329
+ const preferred = targetIds.find((id) => candidates.some(([key]) => key === id))
257
330
  const picked = preferred ? candidates.find(([key]) => key === preferred) : [...candidates].sort((a, b) => (a[1].lastAt || 0) - (b[1].lastAt || 0))[0]
258
331
  if (!picked) return { skipped: 'no-user' }
259
332
  const [userId, user] = picked
@@ -285,14 +358,25 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
285
358
 
286
359
  function startBalanceWatch() {
287
360
  stopBalanceWatch()
288
- const check = () => {
361
+ const runCheck = () => {
362
+ balanceNextAt = Date.now() + 30 * 60_000
363
+ if (!balanceApplicable()) return
289
364
  refreshBalance()
290
- .then(() => maybeAskForTopUp({ dryRun: false }))
365
+ .then((cache) => {
366
+ if (!cache || cache.status !== 'ready') return null
367
+ return maybeAskForTopUp({ dryRun: false })
368
+ })
369
+ .then((result) => {
370
+ if (!result || !result.skipped) return
371
+ if (['enough', 'disabled', 'provider-not-deepseek'].includes(result.skipped)) return
372
+ logger.info('餘額檢查:這次不提醒(' + result.skipped + ')')
373
+ })
291
374
  .catch((error) => logger.warn('餘額檢查失敗:' + errorText(error)))
292
375
  }
293
- const initial = setTimeout(check, 8_000)
376
+ const initial = setTimeout(runCheck, 8_000)
377
+ balanceNextAt = Date.now() + 8_000
294
378
  initial.unref?.()
295
- balanceTimer = setInterval(check, 30 * 60_000)
379
+ balanceTimer = setInterval(runCheck, 30 * 60_000)
296
380
  balanceTimer.unref?.()
297
381
  }
298
382
 
@@ -301,6 +385,7 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
301
385
  clearInterval(balanceTimer)
302
386
  balanceTimer = null
303
387
  }
388
+ balanceNextAt = 0
304
389
  }
305
390
 
306
391
  // ── 個人記憶:清理與每日整理 ────────────────────────────────────────────
@@ -1048,17 +1133,22 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1048
1133
  persona: personaSummary,
1049
1134
  agent: {
1050
1135
  ...runner.stats(),
1136
+ ...modelSelection(),
1051
1137
  timeoutMs: cfg.agent.timeoutMs,
1052
1138
  lastFinishedAt: runner.stats().lastFinishedAt,
1053
1139
  },
1054
1140
  nudge: { ...nudge.stats(), config: cfg.nudge },
1055
1141
  balance: {
1056
1142
  ...balance.get(),
1143
+ applicable: balanceApplicable(),
1144
+ provider: modelSelection().provider,
1057
1145
  enabled: cfg.balance.enabled !== false,
1058
1146
  ask: cfg.balance.ask !== false,
1059
1147
  threshold: Number(cfg.balance.threshold) || 0,
1060
1148
  cooldownHours: Number(cfg.balance.cooldownHours) || 12,
1061
1149
  lastAskAt: Number(state.balance?.lastAskAt) || 0,
1150
+ nextCheckAt: balanceNextAt,
1151
+ targetUserIds: safeArray(cfg.balance?.targetUserIds),
1062
1152
  lastAskText: state.balance?.lastAskText || '',
1063
1153
  low: Boolean(balanceView() && Number(cfg.balance.threshold) > 0 && balanceView().value.total < Number(cfg.balance.threshold)),
1064
1154
  },
@@ -1228,7 +1318,10 @@ export async function create({ ctx, config: pluginConfig, logger: upstream }) {
1228
1318
  personaCache.at = 0
1229
1319
  return created
1230
1320
  }
1321
+ case 'model-catalog':
1322
+ return modelCatalog({ force: payload.force === true })
1231
1323
  case 'balance-refresh':
1324
+ if (!balanceApplicable()) return { skipped: 'provider-not-deepseek', provider: modelSelection().provider }
1232
1325
  return refreshBalance()
1233
1326
  case 'balance-ask':
1234
1327
  return maybeAskForTopUp({ dryRun: payload.dryRun !== false, force: payload.force === true })
@@ -70,6 +70,11 @@ export function buildSection(tieba, date = new Date()) {
70
70
  return `${lines.join('\n')}\n\n`
71
71
  }
72
72
 
73
+ // 只比較內容:標題裡的時間戳每次都會變,不能拿它判斷「語氣有變化」。
74
+ function sansStamp(text) {
75
+ return String(text).replace(/(##\s*貼吧學來的口氣(自動同步 )[^)]*())/, '$1…$2')
76
+ }
77
+
73
78
  function findSection(text) {
74
79
  const heading = /^##\s*貼吧學來的口氣[^\n]*$/m.exec(text)
75
80
  if (!heading) return null
@@ -174,7 +179,7 @@ export function createTiebaSync({ ctx, store, logger }) {
174
179
  const oldText = String(target.text || '')
175
180
  let nextText = applySection(oldText, buildSection(tieba))
176
181
  if (cfg.syncTraits !== false) nextText = applyTraits(nextText, tieba.traits)
177
- const changed = nextText !== oldText
182
+ const changed = sansStamp(nextText) !== sansStamp(oldText)
178
183
  const stateSync = store.getState().sync || {}
179
184
  if (!changed && stateSync.version === tieba.version) {
180
185
  return { skipped: 'unchanged', version: tieba.version, targetId }
@@ -195,17 +200,9 @@ export function createTiebaSync({ ctx, store, logger }) {
195
200
  }
196
201
  writeJsonAtomic(dafeiyuStorePath(), next)
197
202
  logger.info(`已把刷貼吧學到的語氣寫回人設「${target.name || targetId}」(版本 ${tieba.version})`)
198
- if (cfg.reloadPersona !== false) {
199
- try {
200
- reload = await reloadPersonaPlugin(ctx)
201
- if (!reload.ok) logger.warn(`重載大肥魚外掛失敗:${reload.error}`)
202
- } catch (error) {
203
- reload = { ok: false, error: String(error?.message || error) }
204
- logger.warn(`重載大肥魚外掛失敗:${reload.error}`)
205
- }
206
- }
207
203
  }
208
204
 
205
+ // 先記帳再重載:重載要幾百毫秒,中途若插件重啟,新實例讀到的 state 才不會還停在舊的 at 而立刻再跑一次。
209
206
  store.updateState((state) => ({
210
207
  ...state,
211
208
  sync: {
@@ -215,10 +212,21 @@ export function createTiebaSync({ ctx, store, logger }) {
215
212
  samples: tieba.samples.length,
216
213
  changed,
217
214
  backupPath,
218
- reloadOk: reload ? Boolean(reload.ok) : null,
215
+ reloadOk: null,
219
216
  error: '',
220
217
  },
221
218
  }))
219
+
220
+ if (changed && cfg.reloadPersona !== false) {
221
+ try {
222
+ reload = await reloadPersonaPlugin(ctx)
223
+ if (!reload.ok) logger.warn(`重載大肥魚外掛失敗:${reload.error}`)
224
+ } catch (error) {
225
+ reload = { ok: false, error: String(error?.message || error) }
226
+ logger.warn(`重載大肥魚外掛失敗:${reload.error}`)
227
+ }
228
+ store.updateState((state) => ({ ...state, sync: { ...(state.sync || {}), reloadOk: Boolean(reload?.ok) } }))
229
+ }
222
230
  return {
223
231
  at,
224
232
  version: tieba.version,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-line-bot",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "把 LINE 官方帳號(Messaging API)接進 DeepSeek Harness 的插件:內建 Cloudflare Tunnel、人設選擇(大肥魚或插件自訂)、主動傳訊與完整設定面板。",
5
5
  "type": "module",
6
6
  "main": "index.js",