dsh-line-bot 0.1.0 → 0.2.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 +6 -0
- package/README.en.md +172 -0
- package/README.ja.md +172 -0
- package/README.ko.md +172 -0
- package/README.md +25 -0
- package/README.zh-CN.md +172 -0
- package/client.js +92 -0
- package/lib/agent-runner.mjs +29 -16
- package/lib/config.mjs +17 -0
- package/lib/deepseek-balance.mjs +65 -0
- package/lib/host.mjs +142 -1
- package/lib/tieba-context.mjs +83 -0
- package/package.json +2 -1
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# dsh-line-bot
|
|
2
|
+
|
|
3
|
+
<p align="center"><img src="icon.svg" width="96" height="96" alt="dsh-line-bot"></p>
|
|
4
|
+
|
|
5
|
+
**语言 / Languages:** [繁體中文](README.md) · [简体中文](README.zh-CN.md) · [English](README.en.md) · [日本語](README.ja.md) · [한국어](README.ko.md)
|
|
6
|
+
|
|
7
|
+
把 LINE 官方账号(Messaging API)接进 DeepSeek Harness 的插件。回复由 DSH 的 Agent 在**同一个进程内**产生,等于在 LINE 上跟你的 DSH 助理聊天;人设有两种来源:有安装大肥鱼(`dsh-persona-dafeiyu`)时可以直接选它的人设;也可以只用插件内置的自定义人设(不需要任何个性插件)。
|
|
8
|
+
|
|
9
|
+
内置:
|
|
10
|
+
|
|
11
|
+
- **Webhook 服务器**:在本机 `127.0.0.1` 开一个 HTTP server,处理 LINE 的 `X-Line-Signature` 验签、事件去重、每位用户各自的队列。
|
|
12
|
+
- **Cloudflare Tunnel**:一键开 quick tunnel(`*.trycloudflare.com`,免 Cloudflare 账号),或贴入 Cloudflare API Token 创建具名隧道+DNS,取得固定网址;取得网址后自动把 webhook 端点写回 LINE 并跑一次 `webhook/test`。
|
|
13
|
+
- **LINE 凭证**:填 Channel ID+Channel Secret 就能自动签发 access token(`/v2/oauth/accessToken`,约 30 天,快到期会自动续签);也可以直接贴入既有的 Channel Access Token。
|
|
14
|
+
- **人设**:面板可选大肥鱼插件里任何一个内置/自定义人设,或插件内置的自定义人设(存在 `<DSH_HOME>/line-bot/personas.json`,可新增/编辑/导入)。人设是「逐 session」指定,不会改到你 GUI 里其他对话的人设。
|
|
15
|
+
- **主动传讯**:插件内置排程器(不用 launchd),可调概率、间隔、每日/每月上限、最短间隔、安静时段、对方安静多久才可打扰、指定对象与提示词;Agent 觉得不适合时可以回 `SKIP` 跳过。
|
|
16
|
+
- **完整设置面板**:所有设置都在「设置 → LINE Bot」里,存到 `<DSH_HOME>/line-bot/config.json`(权限 600)。
|
|
17
|
+
|
|
18
|
+
> 关于「用 LINE Business ID 登录、由面板代建 bot」:LINE 自 2024-09 起只允许在 [LINE Official Account Manager](https://manager.line.biz/) 手动创建 Messaging API channel,没有公开 API;LINE Login 也只是身份验证,不能取得官方账号的管理权。所以面板不代建 bot,但会给你正确的入口链接,并把「拿到 Channel ID / Secret 之后」的所有步骤自动化(签 token、设 webhook、测试)。
|
|
19
|
+
|
|
20
|
+
## 安装
|
|
21
|
+
|
|
22
|
+
要求:DeepSeek Harness **≥ 0.2.0-rc.1**、Node.js **≥ 22**。人设插件(大肥鱼 `dsh-persona-dafeiyu`)是**可选**的——不装也能用插件内置的自定义人设。
|
|
23
|
+
|
|
24
|
+
### 方式一:面板安装(推荐)
|
|
25
|
+
|
|
26
|
+
DSH → **设置 → 插件 → 安装 bundle**,输入 `dsh-line-bot` 后安装,刷新页面。设置左侧就会多一个 **LINE Bot**。
|
|
27
|
+
|
|
28
|
+
### 方式二:npx 一键安装
|
|
29
|
+
|
|
30
|
+
先完全退出 DSH(桌面版关闭窗口后,确认菜单栏/进程没有残留),再执行:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx dsh-line-bot install
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
它会自动判断要装进哪个 profile(默认 `desktop`,可用 `--profile <名称>` 指定),用 DSH 内置的 pnpm 安装套件、把 bundle 加进 profile,最后提醒你重开 DSH。
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx dsh-line-bot status # 看目前装在哪個 profile、有没有启用
|
|
40
|
+
npx dsh-line-bot uninstall # 移除(设置与凭证会保留)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 方式三:手动
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
dsh plugin --profile desktop add dsh-line-bot
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
再把 `dsh-line-bot` 加进 `~/.dsh/profiles/desktop/package.json` 的 `dsh.profile.bundles` 数组(可参考 `npx dsh-line-bot install` 的做法),或改用面板安装。
|
|
50
|
+
|
|
51
|
+
> 还没发布到 npm 时,也可以直接从 GitHub 安装:
|
|
52
|
+
>
|
|
53
|
+
> ```bash
|
|
54
|
+
> npx github:Hanson0901/dsh-line-bot install --spec github:Hanson0901/dsh-line-bot
|
|
55
|
+
> ```
|
|
56
|
+
>
|
|
57
|
+
> 或从源码:`git clone https://github.com/Hanson0901/dsh-line-bot.git`,在 DSH 面板「安装 bundle」选那个文件夹,或 `dsh plugin --profile desktop add /path/to/dsh-line-bot`。
|
|
58
|
+
|
|
59
|
+
## 第一次上线(5 步)
|
|
60
|
+
|
|
61
|
+
1. **创建/取得凭证**:到 [LINE Official Account Manager](https://manager.line.biz/) 创建官方账号 → [LINE Developers Console](https://developers.line.biz/console/) 创建(或选用)Messaging API channel → 在「Messaging API」分页把 **Use webhook** 打开 → 复制 **Channel ID** 与 **Channel secret**。
|
|
62
|
+
2. **填入面板**:「LINE Bot → LINE 凭证」贴入 Channel ID、Channel Secret,按「保存更改」→「验证并读取 bot 信息」。成功会看到 bot 名称与 Basic ID。
|
|
63
|
+
3. **启动 webhook 服务器**:面板「状态总览」按「启动全部」,或只按「启动」让 webhook server 与 tunnel 都起来(默认会自动启动)。
|
|
64
|
+
4. **开隧道**:Quick tunnel 直接按「启动隧道」;要固定网址就在「Cloudflare Tunnel」贴 API Token →「验证 API Token」→「创建/更新隧道与 DNS」(API Token 权限:Account → Cloudflare Tunnel → Edit、Zone → DNS → Edit、Zone → Zone → Read)。quick tunnel 每次重启网址都会变(插件会自动把新网址写回 LINE webhook);具名隧道的网址则固定。
|
|
65
|
+
5. **自动设置 webhook**:隧道就绪后会自动 PUT 到 LINE;若没成功,按「把隧道网址套到 webhook」再「测试 webhook」。之后在 LINE 加 bot 好友、传消息即可。
|
|
66
|
+
|
|
67
|
+
## 面板各区
|
|
68
|
+
|
|
69
|
+
面板最上方是**固定(sticky)保存栏**:滚到任何位置都看得到「保存更改/刷新」;保存栏下方是二级快速菜单,按一下就会跳到对应区块(滚动时也会自动高亮当前区块)。
|
|
70
|
+
|
|
71
|
+
| 区块 | 内容 |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| 状态总览 | webhook server/隧道/LINE/人设/主动传讯的绿灯状态、公开网址、一键上线与启停 |
|
|
74
|
+
| LINE 凭证 | Channel ID、Channel Secret、Access Token、自动签发、loading 动画、bot 信息、webhook 套用/测试、白名单 |
|
|
75
|
+
| Cloudflare Tunnel | quick/API Token 模式、Token、hostname、隧道名称、Account/Zone ID、下载 cloudflared、启停、自动设置 webhook、自行提供的公开网址 |
|
|
76
|
+
| 回复人设 | 选择人设(大肥鱼或插件自定义)、新增/编辑/删除自定义人设、把目前人设复制成自定义、强制套用、允许用户自己换人设、切换大肥鱼 session 模式、测试 Agent 回复 |
|
|
77
|
+
| 主动传讯 | 开关、概率、间隔、每日/每月上限、最短间隔、安静时段、idle、指定对象、提示词、立即试传、统计 |
|
|
78
|
+
| 用户 | 每位 LINE 用户对应的 DSH session、专属工作文件夹、目前人设(可直接改)、消息数、上次互动、清空记忆/重设 session |
|
|
79
|
+
| 高级设置 | 端口、Agent provider/model/effort/cwd/超时、一人一个工作文件夹(含根目录)、欢迎/错误/空回复、日志、开发用 host 重新载入 |
|
|
80
|
+
|
|
81
|
+
## 配置文件
|
|
82
|
+
|
|
83
|
+
- `<DSH_HOME>/line-bot/config.json`:所有设置,含 LINE secret/Cloudflare token(权限 600,不会回传明文给面板)。
|
|
84
|
+
- `<DSH_HOME>/line-bot/state.json`:LINE 用户 ↔ DSH session 对应、消息数、主动传讯计数、webhook 状态。
|
|
85
|
+
- `<DSH_HOME>/line-bot/logs/YYYY-MM-DD.log`:每日日志(面板也会显示最近 400 行)。
|
|
86
|
+
|
|
87
|
+
`DSH_HOME` 默认是 `~/.dsh`。
|
|
88
|
+
|
|
89
|
+
## 运作方式
|
|
90
|
+
|
|
91
|
+
1. LINE 把消息 POST 到 Cloudflare 隧道 → 插件 webhook server(验签后立刻回 200)。
|
|
92
|
+
2. 依 `userId` 找到(或创建)对应的 DSH session 与工作文件夹,决定这个 session 的人设:选大肥鱼时通过它的 HTTP bridge 指定 session 人设;选插件自定义人设时,先请大肥鱼在该 session 让开(`none`),再由插件把自定义人设注入 system prompt。
|
|
93
|
+
3. 用 `agents.create`/`agents.resume` 在同一个 DSH 进程内创建/接续 Agent,`followup` 一则用户消息,等到 turn 结束、子代理收敛、session flush。
|
|
94
|
+
4. 从 session 事件取出最后一段 assistant 文字,用 LINE reply 回复;reply token 过期就自动改 push。
|
|
95
|
+
5. 主动传讯排程器每分钟检查一次(间隔、安静时段、上限、概率),符合条件就请 Agent 主动说一句,再 push 出去。
|
|
96
|
+
|
|
97
|
+
## 人设来源
|
|
98
|
+
|
|
99
|
+
- **大肥鱼(可选)**:有安装 `dsh-persona-dafeiyu` 时,面板会列出它的人设(内置+你自定义的),LINE 会逐 session 指定,不会改到 GUI 其他对话。需要它处于 `session` 模式;面板会提示并可一键切换。
|
|
100
|
+
- **插件内置自定义人设**:不需要任何个性插件。面板「回复人设 → 自定义人设」可新增/编辑/删除,数据存在 `<DSH_HOME>/line-bot/personas.json`(0600)。选到自定义人设时,插件会请大肥鱼在该 session 停用(若它有安装),再把自定义人设注入这个 agent 的 system prompt。
|
|
101
|
+
- 两种人设可以在同一个下拉菜单里选;用户自己用 LINE 指令切换时也一样(`人设`、`人设 2`、`人设 名字`)。
|
|
102
|
+
|
|
103
|
+
## 每位用户各自的记忆与人设
|
|
104
|
+
|
|
105
|
+
- **记忆不共享**:每个 LINE 用户都有自己的 DSH session,以及自己的 Agent 工作文件夹 `<DSH_HOME>/line-bot/users/<LINE userId>/`。Agent 看到的文件、`AGENTS.md`、工作区记忆都是该用户专属的,不会读到别人的。
|
|
106
|
+
可在「高级设置 → 一人一个文件夹」关闭(关闭后全部共享 `agent.cwd`,等于回到旧行为);也能用 `agent.workspaceRoot` 指定放在哪个根目录。
|
|
107
|
+
- **人设可自选**:「回复人设 → 允许用户自己换人设」开启时,LINE 用户可以传:
|
|
108
|
+
- `人设`(或 `人格`/`persona`)→ 列出可用人设与目前使用中的人设。
|
|
109
|
+
- `人设 2` → 用列表编号切换;`人设 阿茶` → 用名字切换;`人设 预设` → 回到面板默认。
|
|
110
|
+
切换只影响该用户,下一次回复就会套用;管理员也可以直接在「用户」表的人设下拉帮他改。
|
|
111
|
+
- **清空记忆**:用户表按「清空记忆」会删掉该用户的 session 与专属工作文件夹(含里面的文件),重建后从空白开始;只按「重设 session」则保留文件夹。
|
|
112
|
+
|
|
113
|
+
## 贴吧学习自动同步
|
|
114
|
+
|
|
115
|
+
「刷贴吧」插件每天最多演化一次自己的语气(存在 `<DSH_HOME>/tieba-duty/persona.json`),这和 LINE 用的大肥鱼人设(`<DSH_HOME>/storages/dsh-persona-dafeiyu/personas.json`)原本是两套、互不相通。这个插件可以把它们接起来:
|
|
116
|
+
|
|
117
|
+
- 面板「回复人设 → 贴吧学习同步」可设置开关、同步目标人设(只能选大肥鱼的人设;默认关闭,请先选一个)、检查间隔(默认 60 分钟)、是否同步性格数值、写入后是否重载大肥鱼插件。这项功能需要安装大肥鱼插件。
|
|
118
|
+
- 每次检查会比对贴吧人设版本;有新版本才动作。写入时只替换目标人设正文中的 `## 貼吧學來的口氣` 段落(没有就附加),不会覆盖其他手写内容;性格数值(温柔/毒舌/抽象/话痨/发图倾向)可一并更新。
|
|
119
|
+
- 写入前会备份到 `<DSH_HOME>/line-bot/persona-backups/personas-<时间>.json`。
|
|
120
|
+
- 写入后默认会通过 Cordis loader 重启 `dsh-persona-dafeiyu`,让它重新读取 store;重启失败只会记警告,人设文字仍已写入,下次插件重载时生效。
|
|
121
|
+
- 贴吧插件若没在跑、或贴吧人设不存在,检查会跳过(`pending` 显示尚未同步的原因)。
|
|
122
|
+
|
|
123
|
+
## 贴吧动态(让角色知道自己刚刷过)
|
|
124
|
+
|
|
125
|
+
面板「进阶设置 → 让 Agent 知道贴吧动态」(默认开)会在每次回复前,把「刷贴吧」插件留下的记录整理成一小段背景事实注入 system prompt:
|
|
126
|
+
|
|
127
|
+
- 最近一次刷贴吧的时间(例如「昨天 22:41,5 小时前」)
|
|
128
|
+
- 今天已刷的场次(中午/晚上)
|
|
129
|
+
- 今天的贴吧日报有没有生成
|
|
130
|
+
- 贴吧人设版本与最后一次回复时间
|
|
131
|
+
|
|
132
|
+
用户问「你刚刚有刷贴吧吗?」「今天刷过了吗?」时,角色会按实际时间回答,不会编造。这个功能只读 `<DSH_HOME>/tieba-duty/` 的文件,不需要贴吧插件正在运行,也不会写入任何东西;读不到记录时就不注入。
|
|
133
|
+
|
|
134
|
+
## DeepSeek 余额
|
|
135
|
+
|
|
136
|
+
面板的「DeepSeek 余额」卡会读取 DSH 当前登录的 DeepSeek 账号钱包余额(只读,不会充值):
|
|
137
|
+
|
|
138
|
+
- 每轮回复都会把当前余额放进角色的背景信息,所以可以直接问它「我还剩多少」。
|
|
139
|
+
- 低于门槛(默认 ¥10,可在面板调整)时,它会用**自己的语气**发一条消息提醒你充值;内容不是固定模板。
|
|
140
|
+
- 提醒有冷却时间(默认 12 小时),并沿用「主动传讯」的静音时段与目标用户设置。
|
|
141
|
+
- 需要 DSH 已登录 DeepSeek 账号(deepseek-account)。未登录或该 profile 没有这个服务时,卡片会说明原因,其他功能不受影响。
|
|
142
|
+
- 门槛用的是钱包金额,不是 token 数。
|
|
143
|
+
|
|
144
|
+
## 疑难排解
|
|
145
|
+
|
|
146
|
+
- **面板显示「无法连接到 LINE Bot 运行时」**:webServer 可能还没就绪,刷新即可;仍失败就看 DSH 日志里 `[line-bot]` 的消息。
|
|
147
|
+
- **webhook 测试失败**:确认 `Use webhook` 已在 LINE console 打开、隧道网址是 https、且本机 webhook server 正在跑(面板绿灯)。
|
|
148
|
+
- **改了 lib/*.mjs 没生效**:按「高级设置 → 重新载入 host 模块(开发用)」,不用重装插件。
|
|
149
|
+
- **贴吧同步没动作**:先看面板同步状态的 `lastError`;确认贴吧插件的人设文件存在、目标人设 id 正确。按「预览差异」可比对目前段落与即将写入的内容。
|
|
150
|
+
- **cloudflared 找不到**:按「下载 cloudflared」,插件会抓官方 release 放到 `<DSH_HOME>/line-bot/bin/`;也可以自己放一份并在 `tunnel.binaryPath` 指定。
|
|
151
|
+
- **Agent 回复 403(模型只开放给 Codex)**:在「高级设置 → Agent 模型」把 provider/model 改成 `deepseek-account` / `deepseek-flash`(本机默认已设好),保存后再测一次。
|
|
152
|
+
- **人设没套用**:选大肥鱼时,它必须是 `session` 模式;面板会提示,按「切换到大肥鱼 session 模式」即可(这是它官方支持的设置,只影响「可不可以逐 session 指定人设」)。选插件自定义人设则不受此限制。
|
|
153
|
+
- **Cloudflare 回 530 / error code 1016**:具名隧道的 DNS CNAME 目标必须是 `<tunnel-id>.cfargotunnel.com`(不是 `.cfd.tunnel`);另外该子域名若同时被 A 记录占用,边缘会先回那台主机,请确认 DNS 只留一笔 CNAME。
|
|
154
|
+
|
|
155
|
+
## 隐私
|
|
156
|
+
|
|
157
|
+
- 这个插件不连任何第三方服务器(除了你设置的 Cloudflare API、LINE API)。没有遥测、没有账号。
|
|
158
|
+
- 所有设置与凭证只存在本机 `<DSH_HOME>/line-bot/config.json`(权限 600);面板只显示遮罩后的值。
|
|
159
|
+
- LINE 用户的 session、工作文件夹与人设选择都存在本机,不会上传。
|
|
160
|
+
- 仓库与 npm 套件不含任何人的凭证、LINE userId、Cloudflare 账号或域名。
|
|
161
|
+
|
|
162
|
+
## 安全提醒
|
|
163
|
+
|
|
164
|
+
- 这等同把 DSH Agent 接到公开的 LINE 账号:拿到 bot 的人可以让 Agent 使用你电脑上的工具。建议在「白名单用户 ID」填入自己的 LINE userId,只允许自己使用。
|
|
165
|
+
- 所有凭证都存在本机 `config.json`(600),面板只会看到遮罩后的值;API 只挂在 `127.0.0.1` 的 DSH webServer 上。
|
|
166
|
+
- Cloudflare API Token 建议只给必要权限,并在不用时撤销。
|
|
167
|
+
|
|
168
|
+
## 开发
|
|
169
|
+
|
|
170
|
+
- host 逻辑:`lib/*.mjs`(`util`/`config`/`line-api`/`persona`/`agent-runner`/`webhook-server`/`cloudflare`/`tunnel`/`nudge`/`tieba-sync`/`host`)。
|
|
171
|
+
- 面板:`client.js`(`window.__ModuleLoader__` 注册 `settings.section`)。
|
|
172
|
+
- 进入点:`index.js`(挂 `/api/line-bot`、dev reload、卸载清理)。
|
package/client.js
CHANGED
|
@@ -13,6 +13,7 @@ window.__ModuleLoader__.load({
|
|
|
13
13
|
// 二級選單:卡片區塊的快速跳轉。
|
|
14
14
|
const NAV = [
|
|
15
15
|
{ key: 'overview', label: '狀態總覽' },
|
|
16
|
+
{ key: 'balance', label: 'DeepSeek 餘額' },
|
|
16
17
|
{ key: 'line', label: 'LINE 憑證' },
|
|
17
18
|
{ key: 'tunnel', label: 'Cloudflare' },
|
|
18
19
|
{ key: 'persona', label: '回覆人設' },
|
|
@@ -291,6 +292,8 @@ window.__ModuleLoader__.load({
|
|
|
291
292
|
'persona-status': '重新讀取人設',
|
|
292
293
|
'persona-mode': '切換人設模式',
|
|
293
294
|
'tieba-preview': '預覽貼吧同步差異',
|
|
295
|
+
'balance-refresh': '查詢 DeepSeek 餘額',
|
|
296
|
+
'balance-ask': '餘額提醒',
|
|
294
297
|
'tieba-sync': '同步貼吧學習',
|
|
295
298
|
'local-persona-list': '讀取自訂人設',
|
|
296
299
|
'local-persona-get': '讀取自訂人設內容',
|
|
@@ -313,6 +316,7 @@ window.__ModuleLoader__.load({
|
|
|
313
316
|
const [toast, setToast] = useState(null)
|
|
314
317
|
const [active, setActive] = useState('overview')
|
|
315
318
|
const [syncPreview, setSyncPreview] = useState(null)
|
|
319
|
+
const [balancePreview, setBalancePreview] = useState(null)
|
|
316
320
|
const [localEdit, setLocalEdit] = useState({ id: '', name: '', text: '' })
|
|
317
321
|
|
|
318
322
|
const dirty = useMemo(() => {
|
|
@@ -407,6 +411,31 @@ window.__ModuleLoader__.load({
|
|
|
407
411
|
}
|
|
408
412
|
}
|
|
409
413
|
|
|
414
|
+
async function previewBalance() {
|
|
415
|
+
setBusy('balance-ask')
|
|
416
|
+
setToast(null)
|
|
417
|
+
try {
|
|
418
|
+
if (dirty) await persist(false)
|
|
419
|
+
const value = await jpost('/action', { action: 'balance-ask', payload: { dryRun: true, force: true } })
|
|
420
|
+
if (value?.status) {
|
|
421
|
+
setStatus(value.status)
|
|
422
|
+
setDraft(stripSecrets(value.status.config))
|
|
423
|
+
}
|
|
424
|
+
setBalancePreview(value?.result || null)
|
|
425
|
+
setToast({ kind: 'ok', text: value?.result?.text ? '已產生提醒文字,還沒有送出。' : '目前不需要提醒。' })
|
|
426
|
+
} catch (err) {
|
|
427
|
+
setToast({ kind: 'error', text: '預覽提醒失敗:' + (err.message || err) })
|
|
428
|
+
} finally {
|
|
429
|
+
setBusy('')
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function askBalance() {
|
|
434
|
+
if (typeof window !== 'undefined' && !window.confirm('現在真的用 LINE 送一則餘額提醒給使用者?')) return
|
|
435
|
+
setBalancePreview(null)
|
|
436
|
+
await act('balance-ask', { dryRun: false, force: true })
|
|
437
|
+
}
|
|
438
|
+
|
|
410
439
|
async function previewSync() {
|
|
411
440
|
setBusy('tieba-preview')
|
|
412
441
|
setToast(null)
|
|
@@ -533,6 +562,10 @@ window.__ModuleLoader__.load({
|
|
|
533
562
|
const agentCfg = cfg.agent || {}
|
|
534
563
|
const nudgeCfg = cfg.nudge || {}
|
|
535
564
|
const syncCfg = cfg.sync || {}
|
|
565
|
+
const balanceCfg = cfg.balance || {}
|
|
566
|
+
const balanceStatus = status.balance || {}
|
|
567
|
+
const balanceSymbol = balanceStatus.cny ? '¥' : balanceStatus.usd ? '$' : '¥'
|
|
568
|
+
const balanceTotal = balanceStatus.cny ? balanceStatus.cny.total : balanceStatus.usd ? balanceStatus.usd.total : null
|
|
536
569
|
const syncStats = status.sync || {}
|
|
537
570
|
const runtime = status
|
|
538
571
|
const persona = status.persona || {}
|
|
@@ -615,6 +648,63 @@ window.__ModuleLoader__.load({
|
|
|
615
648
|
runtime.agent?.lastError ? h('div', { style: S.errBox }, `Agent:${runtime.agent.lastError}`) : null,
|
|
616
649
|
),
|
|
617
650
|
|
|
651
|
+
// 1.5 DeepSeek 餘額
|
|
652
|
+
h(
|
|
653
|
+
Card,
|
|
654
|
+
{
|
|
655
|
+
id: sectionDomId('balance'),
|
|
656
|
+
title: 'DeepSeek 餘額',
|
|
657
|
+
desc: '讓角色知道 API 錢包剩多少;低於門檻時由角色自己想一句話提醒你儲值(會避開主動傳訊的靜音時段)。',
|
|
658
|
+
right: h(Btn, { onClick: () => act('balance-refresh'), disabled: !!busy }, busy === 'balance-refresh' ? '查詢中…' : '重新查詢'),
|
|
659
|
+
},
|
|
660
|
+
h(
|
|
661
|
+
'div',
|
|
662
|
+
{ style: S.row },
|
|
663
|
+
h(Badge, { ok: balanceStatus.status === 'ready' ? !balanceStatus.low : false }, balanceTotal != null ? ('可用 ' + balanceSymbol + Number(balanceTotal).toFixed(2)) : ('餘額:' + (balanceStatus.status || '未查詢'))),
|
|
664
|
+
h(Badge, { ok: balanceCfg.enabled !== false }, '監控' + (balanceCfg.enabled !== false ? '開啟' : '關閉')),
|
|
665
|
+
h(Badge, { ok: balanceCfg.ask !== false }, '主動提醒' + (balanceCfg.ask !== false ? '開啟' : '關閉')),
|
|
666
|
+
),
|
|
667
|
+
balanceStatus.status === 'ready'
|
|
668
|
+
? h(KV, {
|
|
669
|
+
rows: [
|
|
670
|
+
['目前餘額', (balanceStatus.cny ? '¥' + Number(balanceStatus.cny.total).toFixed(2) + '(一般 ¥' + Number(balanceStatus.cny.normal).toFixed(2) + '、贈金 ¥' + Number(balanceStatus.cny.bonus).toFixed(2) + ')' : '$' + Number(balanceStatus.usd.total).toFixed(2))],
|
|
671
|
+
['上次查詢', fmtTime(balanceStatus.at)],
|
|
672
|
+
['上次提醒', balanceStatus.lastAskAt ? fmtTime(balanceStatus.lastAskAt) : '還沒提醒過'],
|
|
673
|
+
['提醒門檻', '¥' + (balanceCfg.threshold || 0) + '(0 = 不自動提醒)'],
|
|
674
|
+
],
|
|
675
|
+
})
|
|
676
|
+
: h(
|
|
677
|
+
'div',
|
|
678
|
+
{ style: S.note },
|
|
679
|
+
balanceStatus.status === 'unavailable'
|
|
680
|
+
? '這個 DSH profile 沒有 deepseek-account 服務,抓不到餘額;其他功能不受影響。'
|
|
681
|
+
: balanceStatus.status === 'signed-out'
|
|
682
|
+
? '目前沒有登入 DeepSeek 帳號(或登入已過期),所以查不到餘額。'
|
|
683
|
+
: balanceStatus.error
|
|
684
|
+
? ('查詢失敗:' + balanceStatus.error)
|
|
685
|
+
: '按「重新查詢」讀取目前的 DeepSeek API 餘額。',
|
|
686
|
+
),
|
|
687
|
+
h(
|
|
688
|
+
'div',
|
|
689
|
+
{ style: S.grid },
|
|
690
|
+
h(Field, { label: '低於多少提醒(¥)', hint: '0 = 不自動提醒;低於門檻時由角色主動傳訊息' }, h(Num, { value: balanceCfg.threshold, min: 0, max: 100000, step: 1, onChange: (value) => update({ balance: { threshold: value } }) })),
|
|
691
|
+
h(Field, { label: '提醒冷卻(小時)', hint: '同一段時間內最多提醒一次' }, h(Num, { value: balanceCfg.cooldownHours, min: 1, max: 168, step: 1, onChange: (value) => update({ balance: { cooldownHours: value } }) })),
|
|
692
|
+
),
|
|
693
|
+
h(
|
|
694
|
+
'div',
|
|
695
|
+
{ style: S.row },
|
|
696
|
+
h(Toggle, { checked: balanceCfg.enabled !== false, onChange: (value) => update({ balance: { enabled: value } }), label: '在每輪回覆裡提供餘額給角色', hint: '關掉就完全不知道餘額' }),
|
|
697
|
+
h(Toggle, { checked: balanceCfg.ask !== false, onChange: (value) => update({ balance: { ask: value } }), label: '低於門檻時主動提醒', hint: '由角色自己決定怎麼問' }),
|
|
698
|
+
),
|
|
699
|
+
h(
|
|
700
|
+
'div',
|
|
701
|
+
{ style: S.row },
|
|
702
|
+
h(Btn, { onClick: previewBalance, disabled: !!busy }, busy === 'balance-ask' ? '產生中…' : '預覽提醒(不送出)'),
|
|
703
|
+
h(Btn, { tone: 'primary', onClick: askBalance, disabled: !!busy }, '立即提醒一次'),
|
|
704
|
+
),
|
|
705
|
+
balancePreview && balancePreview.text ? h('div', { style: S.panelBox }, h('div', { style: S.fieldTitle }, '角色會傳這則:'), h('pre', { style: S.pre }, balancePreview.text)) : null,
|
|
706
|
+
),
|
|
707
|
+
|
|
618
708
|
// 2. LINE 憑證
|
|
619
709
|
h(
|
|
620
710
|
Card,
|
|
@@ -871,6 +961,8 @@ window.__ModuleLoader__.load({
|
|
|
871
961
|
h(Field, { label: '每位使用者獨立工作資料夾', hint: '開啟=每個 LINE 使用者有自己的 cwd(檔案、AGENTS.md、工作區記憶互不相通)' },
|
|
872
962
|
h(Toggle, { checked: agentCfg.perUserWorkspace !== false, onChange: (value) => update({ agent: { perUserWorkspace: value } }), label: '一人一個資料夾', hint: '預設開;關閉則全部共用 agent.cwd' })),
|
|
873
963
|
h(Field, { label: '使用者工作資料夾根目錄(選填)', hint: '留空=<資料夾>/users/<LINE userId>' }, h(Text, { value: agentCfg.workspaceRoot, onChange: (value) => update({ agent: { workspaceRoot: value } }), placeholder: '留空=自動', mono: true })),
|
|
964
|
+
h(Field, { label: '讓 Agent 知道貼吧動態', hint: '每輪注入「剛剛有沒有刷貼吧、今天刷過幾場、有沒有日報」;貼吧外掛的紀錄不在時自動略過。' },
|
|
965
|
+
h(Toggle, { checked: agentCfg.tiebaContext !== false, onChange: (value) => update({ agent: { tiebaContext: value } }), label: '注入貼吧動態', hint: '預設開' })),
|
|
874
966
|
h(Field, { label: 'Agent 逾時(毫秒)' }, h(Num, { value: agentCfg.timeoutMs, min: 10000, max: 900000, step: 1000, onChange: (value) => update({ agent: { timeoutMs: value } }) })),
|
|
875
967
|
h(Field, { label: 'Agent provider(留空=跟隨 DSH 預設)' }, h(Text, { value: agentCfg.provider, onChange: (value) => update({ agent: { provider: value } }), placeholder: 'deepseek-account' })),
|
|
876
968
|
h(Field, { label: 'Agent model(留空=跟隨 DSH 預設)' }, h(Text, { value: agentCfg.model, onChange: (value) => update({ agent: { model: value } }), placeholder: 'deepseek-flash' })),
|
package/lib/agent-runner.mjs
CHANGED
|
@@ -78,14 +78,14 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
|
|
|
78
78
|
return target
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
function
|
|
81
|
+
// 把「插件自己的人設」與「貼吧動態」掛進這個 agent 的 system prompt。
|
|
82
|
+
// 自訂人設只有選了才注入;大肥魚的人設走它自己的 session 指定。
|
|
83
|
+
function agentSetup({ personaText, contextText }) {
|
|
84
84
|
return (agentCtx) => {
|
|
85
85
|
try {
|
|
86
86
|
const prompt = agentCtx?.systemPrompt
|
|
87
87
|
if (!prompt?.section) {
|
|
88
|
-
logger?.warn?.('這個 profile 沒有 systemPrompt
|
|
88
|
+
logger?.warn?.('這個 profile 沒有 systemPrompt,自訂人設與貼吧動態都不會被注入')
|
|
89
89
|
return
|
|
90
90
|
}
|
|
91
91
|
let order = 100
|
|
@@ -95,17 +95,30 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
|
|
|
95
95
|
} catch {
|
|
96
96
|
/* 用預設順序 */
|
|
97
97
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
98
|
+
if (personaText) {
|
|
99
|
+
agentCtx.effect(
|
|
100
|
+
() => prompt.section({
|
|
101
|
+
name: 'line-bot:persona',
|
|
102
|
+
order,
|
|
103
|
+
text: personaText,
|
|
104
|
+
interpolate: false,
|
|
105
|
+
}),
|
|
106
|
+
'line-bot: persona section',
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
if (contextText) {
|
|
110
|
+
agentCtx.effect(
|
|
111
|
+
() => prompt.section({
|
|
112
|
+
name: 'line-bot:tieba-context',
|
|
113
|
+
order: order + 20,
|
|
114
|
+
text: contextText,
|
|
115
|
+
interpolate: false,
|
|
116
|
+
}),
|
|
117
|
+
'line-bot: tieba context section',
|
|
118
|
+
)
|
|
119
|
+
}
|
|
107
120
|
} catch (error) {
|
|
108
|
-
logger?.warn?.(
|
|
121
|
+
logger?.warn?.(`注入人設/貼吧動態失敗:${error?.message || error}`)
|
|
109
122
|
}
|
|
110
123
|
}
|
|
111
124
|
}
|
|
@@ -148,7 +161,7 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
|
|
|
148
161
|
}
|
|
149
162
|
}
|
|
150
163
|
|
|
151
|
-
async function turn({ sessionId, text, persona, personaClient, personaText, cwd: cwdOverride }) {
|
|
164
|
+
async function turn({ sessionId, text, persona, personaClient, personaText, contextText, cwd: cwdOverride }) {
|
|
152
165
|
const cfg = getConfig()
|
|
153
166
|
const timeoutMs = cfg.agent.timeoutMs || 180000
|
|
154
167
|
const { options } = agentOptions()
|
|
@@ -162,7 +175,7 @@ export function createAgentRunner({ ctx, logger, getConfig }) {
|
|
|
162
175
|
let offEnd = null
|
|
163
176
|
|
|
164
177
|
try {
|
|
165
|
-
const setup = personaText ?
|
|
178
|
+
const setup = personaText || contextText ? agentSetup({ personaText, contextText }) : undefined
|
|
166
179
|
const opened = await openAgent(sessionId, options, cwd, setup)
|
|
167
180
|
agent = opened.agent
|
|
168
181
|
handle = opened.handle
|
package/lib/config.mjs
CHANGED
|
@@ -69,6 +69,8 @@ export const DEFAULT_CONFIG = {
|
|
|
69
69
|
// 每位 LINE 使用者一個獨立工作資料夾:session/檔案/AGENTS.md 不互通。
|
|
70
70
|
perUserWorkspace: true,
|
|
71
71
|
workspaceRoot: '',
|
|
72
|
+
// 每輪把「剛剛有沒有刷貼吧」等貼吧動態注入 system prompt。
|
|
73
|
+
tiebaContext: true,
|
|
72
74
|
},
|
|
73
75
|
sync: {
|
|
74
76
|
// 每天把刷貼吧學到的語氣寫回指定的人設本文(預設關閉,請在面板選一個「自訂人設」)。
|
|
@@ -91,6 +93,13 @@ export const DEFAULT_CONFIG = {
|
|
|
91
93
|
targetUserIds: [],
|
|
92
94
|
prompt: '',
|
|
93
95
|
},
|
|
96
|
+
balance: {
|
|
97
|
+
// 讓角色知道 DeepSeek API 餘額;低於門檻時由角色自己想句話提醒主人儲值。
|
|
98
|
+
enabled: true,
|
|
99
|
+
threshold: 10,
|
|
100
|
+
ask: true,
|
|
101
|
+
cooldownHours: 12,
|
|
102
|
+
},
|
|
94
103
|
log: {
|
|
95
104
|
maxLines: 400,
|
|
96
105
|
},
|
|
@@ -188,6 +197,7 @@ export function sanitizePatch(current, patch) {
|
|
|
188
197
|
next.agent.timeoutMs = clampInt(next.agent.timeoutMs, 10000, 900000, 180000)
|
|
189
198
|
next.agent.perUserWorkspace = Boolean(next.agent.perUserWorkspace)
|
|
190
199
|
next.agent.workspaceRoot = String(next.agent.workspaceRoot || '').trim()
|
|
200
|
+
next.agent.tiebaContext = Boolean(next.agent.tiebaContext)
|
|
191
201
|
|
|
192
202
|
const sync = next.sync
|
|
193
203
|
sync.enabled = Boolean(sync.enabled)
|
|
@@ -196,6 +206,12 @@ export function sanitizePatch(current, patch) {
|
|
|
196
206
|
sync.syncTraits = Boolean(sync.syncTraits)
|
|
197
207
|
sync.reloadPersona = Boolean(sync.reloadPersona)
|
|
198
208
|
|
|
209
|
+
const balance = next.balance
|
|
210
|
+
balance.enabled = Boolean(balance.enabled)
|
|
211
|
+
balance.ask = Boolean(balance.ask)
|
|
212
|
+
balance.threshold = clampFloat(balance.threshold, 0, 100000, 10)
|
|
213
|
+
balance.cooldownHours = clampInt(balance.cooldownHours, 1, 168, 12)
|
|
214
|
+
|
|
199
215
|
const nudge = next.nudge
|
|
200
216
|
nudge.enabled = Boolean(nudge.enabled)
|
|
201
217
|
nudge.chance = clampFloat(nudge.chance, 0, 1, 0.18)
|
|
@@ -274,6 +290,7 @@ export function normalizeState(raw) {
|
|
|
274
290
|
return {
|
|
275
291
|
users: isPlainObject(state.users) ? state.users : {},
|
|
276
292
|
nudge: isPlainObject(state.nudge) ? state.nudge : {},
|
|
293
|
+
balance: isPlainObject(state.balance) ? state.balance : {},
|
|
277
294
|
sync: isPlainObject(state.sync) ? state.sync : {},
|
|
278
295
|
webhook: isPlainObject(state.webhook) ? state.webhook : null,
|
|
279
296
|
lastEventAt: Number(state.lastEventAt) || 0,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// 讀 DSH 登入的 DeepSeek 帳號餘額(走 deepseek-account 的 getBalance),快取起來給
|
|
2
|
+
// LINE 的 system prompt 與「餘額不足時主動找人」使用。只讀,不會儲值或改設定。
|
|
3
|
+
export function createBalanceMonitor({ ctx, logger }) {
|
|
4
|
+
let cache = {
|
|
5
|
+
status: 'idle', // idle | ready | signed-out | unavailable | failed | error
|
|
6
|
+
at: 0,
|
|
7
|
+
error: '',
|
|
8
|
+
wallets: [],
|
|
9
|
+
cny: null,
|
|
10
|
+
usd: null,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function accountService() {
|
|
14
|
+
try {
|
|
15
|
+
return ctx.get?.('deepseekAccount') || ctx.get?.('account') || null
|
|
16
|
+
} catch {
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalize(detail) {
|
|
22
|
+
const normal = Array.isArray(detail?.value) ? detail.value : []
|
|
23
|
+
const bonus = Array.isArray(detail?.bonusWallets) ? detail.bonusWallets : []
|
|
24
|
+
const wallets = [...normal, ...bonus].filter((wallet) => wallet && typeof wallet.balance === 'string')
|
|
25
|
+
const sum = (currency, source) => source
|
|
26
|
+
.filter((wallet) => wallet.currency === currency)
|
|
27
|
+
.reduce((total, wallet) => total + (Number(wallet.balance) || 0), 0)
|
|
28
|
+
const make = (currency) => {
|
|
29
|
+
const normalSum = sum(currency, normal)
|
|
30
|
+
const bonusSum = sum(currency, bonus)
|
|
31
|
+
return { normal: normalSum, bonus: bonusSum, total: normalSum + bonusSum }
|
|
32
|
+
}
|
|
33
|
+
return { wallets, cny: wallets.length ? make('CNY') : null, usd: wallets.length ? make('USD') : null }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function fetchNow() {
|
|
37
|
+
const service = accountService()
|
|
38
|
+
if (!service?.getBalance) {
|
|
39
|
+
cache = { status: 'unavailable', at: Date.now(), error: '找不到 deepseekAccount 服務', wallets: [], cny: null, usd: null }
|
|
40
|
+
return cache
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const detail = await service.getBalance({
|
|
44
|
+
version: '0.2.1',
|
|
45
|
+
locale: 'zh-TW',
|
|
46
|
+
timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60,
|
|
47
|
+
})
|
|
48
|
+
if (!detail) {
|
|
49
|
+
cache = { status: 'signed-out', at: Date.now(), error: '', wallets: [], cny: null, usd: null }
|
|
50
|
+
return cache
|
|
51
|
+
}
|
|
52
|
+
if (detail.status !== 'ready') {
|
|
53
|
+
cache = { status: 'failed', at: Date.now(), error: '帳號餘額查詢失敗', wallets: [], cny: null, usd: null }
|
|
54
|
+
return cache
|
|
55
|
+
}
|
|
56
|
+
cache = { status: 'ready', at: Date.now(), error: '', ...normalize(detail) }
|
|
57
|
+
} catch (error) {
|
|
58
|
+
cache = { status: 'error', at: Date.now(), error: error?.message || String(error), wallets: [], cny: null, usd: null }
|
|
59
|
+
logger?.warn?.(`讀取 DeepSeek 餘額失敗:${cache.error}`)
|
|
60
|
+
}
|
|
61
|
+
return cache
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { fetchNow, get: () => cache }
|
|
65
|
+
}
|