oh-my-im 0.1.21 → 0.2.0

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/README.md CHANGED
@@ -8,10 +8,10 @@
8
8
 
9
9
  - 单聊机器人:按用户白名单接收钉钉单聊,支持文本、图片、文件、语音等可解析消息。
10
10
  - 群消息监听:消费 DWS 全群消息事件,再按“群 + 发送人”规则做本地过滤。
11
- - Agent 切换:在管理页选择 Codex、Pi 或 OpenCode,也可以在会话中使用已配置的关键词切换。
12
- - Agent 模型:管理页可分别配置 Pi 和 OpenCode 模型;Codex 始终使用系统 Codex CLI 已设置的默认模型。
13
- - 回复方式:在管理页选择互动卡片或普通文本;该设置同时作用于群聊和私聊。普通文本模式只发送 Agent 最终结果。
14
- - 互动卡片:显示处理中、工具调用和最终回复;群聊支持 Markdown 或纯文本格式。
11
+ - Agent 切换:在管理页选择 Codex、Pi 或 OpenCode(作为默认值),也可以在会话中使用已配置的关键词切换;切换结果**按会话(每个群/每个私聊)独立保存**,重启后各自沿用,互不影响。
12
+ - Agent 模型:管理页可分别配置 Codex、Pi 和 OpenCode 模型;Codex 的可选模型来自 `~/.codex/config.toml` 里 `model_catalog_json` 指向的目录(不配则沿用 Codex CLI 默认模型,配置后通过 `--model` 传入)。
13
+ - 回复方式:在管理页「机器人配置」中选择普通卡片、AI 卡片或普通消息;该设置同时作用于群聊和私聊。
14
+ - 互动卡片:显示处理中和最终回复,内容始终按 Markdown 渲染。
15
15
  - Session 管理:单聊用户只能查看和切换自己工作目录下的 Session;超级管理员可管理其他工作目录。
16
16
  - 本地控制台:配置钉钉凭证、群规则、单聊白名单、Agent、提示词和指令关键词。
17
17
  - 进程管理:使用 `omi` 启动、停止、重启、更新和查看 worker 状态。
@@ -20,9 +20,9 @@
20
20
 
21
21
  ```text
22
22
  钉钉单聊 ── Stream Mode ──> bot-worker ──┐
23
- ├─> Codex CLI / Pi RPC
23
+ ├─> Codex CLI / Pi RPC / OpenCode CLI
24
24
  DWS 群事件 ── DWS CLI ──> group-worker ──┘
25
- └─> 钉钉文本 / StandardCard
25
+ └─> 钉钉文本 / StandardCard / AI 卡片
26
26
 
27
27
  dashboard-worker ──> http://127.0.0.1:12525
28
28
  ```
@@ -37,6 +37,27 @@ dashboard-worker ──> http://127.0.0.1:12525
37
37
 
38
38
  群消息不会因为出现在 DWS 全群事件流中就自动触发 Agent,只有命中管理页配置的群和发送人才会进入处理队列。每个群独立排队,并复用该群对应 Agent 的 Session。
39
39
 
40
+ ### 分层
41
+
42
+ 代码按「入口 → 业务编排 → 外部适配 → 基础库」分层,避免相互缠绕:
43
+
44
+ ```text
45
+ 入口 omi.ts group-worker.ts bot-worker.ts dashboard-worker.ts
46
+
47
+ 业务编排 bot-app.ts(单聊) dws-dashboard.ts(管理页 + 配置模型)
48
+
49
+ 外部适配 dingtalk/(Stream、普通卡片、AI 卡片、机器人、Markdown)
50
+ dws/(DWS CLI 调用、群历史补偿)
51
+ agents/(Codex / Pi / OpenCode 进程适配)
52
+
53
+ 基础库 core/(config、logger、version、回复历史、群控制指令)
54
+ ```
55
+
56
+ - 外部依赖(CLI、钉钉接口)只在适配层出现;上层只依赖统一的 `runAgent` / `listAgentSessions` / 卡片接口。
57
+ - 单个会话的状态(Agent、Session、工作目录、卡片)按 `群 ID` 或 `私聊 ID` 隔离,互不影响。
58
+
59
+ ---
60
+
40
61
  ## 前置条件
41
62
 
42
63
  - Node.js `>= 20`
@@ -94,14 +115,21 @@ http://127.0.0.1:12525
94
115
 
95
116
  ### Agent 模型
96
117
 
97
- 管理页展示 Pi 和 OpenCode 的模型选择:
118
+ 管理页展示三个 Agent 的模型选择:
98
119
 
99
120
  ```text
100
- Pi 默认模型 -> 所有使用 Pi 的群聊和单聊
101
- OpenCode 默认模型 -> 所有使用 OpenCode 的群聊和单聊
121
+ Codex 默认模型 -> 所有使用 Codex 的群聊和单聊(通过 --model 传入,留空则用 Codex CLI 默认模型)
122
+ Pi 默认模型 -> 所有使用 Pi 的群聊和单聊(通过 --model 传入)
123
+ OpenCode 默认模型 -> 所有使用 OpenCode 的群聊和单聊(通过 --model 传入)
102
124
  ```
103
125
 
104
- Codex 始终不会传递 `--model`,也不读取 Web 后台或系统变量中的模型值,由 Codex CLI 自己决定默认模型。Pi 的模型列表通过 `pi --list-models` 获取,OpenCode 的模型列表通过 `opencode models` 获取;如果对应 CLI 暂不可用,可以先完成 CLI 登录或直接保留默认模型。
126
+ 模型来源:
127
+
128
+ - **Codex**:可选列表来自 `~/.codex/config.toml` 中 `model_catalog_json` 指向的模型目录(默认 `~/.codex/models.json`),取其中的 `slug`;写入的是不带 provider 的模型名。
129
+ - **Pi**:`pi --list-models`,保存格式为 `provider/model`。
130
+ - **OpenCode**:`opencode models`,保存格式为 `provider/model`。
131
+
132
+ 三个列表都不做白名单过滤——CLI 能列出什么就展示什么;对应 CLI 暂时不可用时可以直接留空,使用其默认模型。
105
133
 
106
134
  ## `omi` 命令
107
135
 
@@ -135,19 +163,21 @@ omi
135
163
 
136
164
  管理页默认只绑定 `127.0.0.1`,默认端口为 `12525`。可配置内容包括:
137
165
 
138
- - 默认 Agent:Codex 或 Pi,以及可选的模型名。
166
+ - 默认 Agent:Codex、PiOpenCode,以及各自可选的模型名(也支持在会话中用关键词切换,切换结果按会话保存)。
139
167
  - 钉钉应用凭证、机器人名称、机器人发送者 ID。
140
168
  - 单聊开关、单聊授权人员和 Session 超级管理员。
141
169
  - 群监听规则:群、群成员和每次个人历史消息拉取参数。
142
170
  - 群提示词后缀、互动卡片格式、卡片更新间隔和是否显示耗时。
143
- - 提示词后缀会追加在用户消息事件 JSON 的最下方。
144
- - Agent 回复方式:互动卡片模式会创建并更新卡片;普通文本模式只在 Agent 完成后发送最终结果。
171
+ - 提示词后缀会追加在群聊用户消息的最下方,对 Codex、Pi、OpenCode 三个 Agent 都生效。
172
+ - 工作目录自动按会话生成,无需配置:群聊为 `~/.oh-my-im/group/<群名>`,私聊为 `~/.oh-my-im/users/<发送人名称>`(例如 `~/.oh-my-im/users/杜振训`),三个 Agent 使用同一个工作目录。
173
+ - Agent 回复方式:在「机器人配置」页选择「普通卡片 / AI 卡片 / 普通消息」,群聊和私聊统一生效。普通卡片会创建并更新互动卡片(此时显示卡片设置);AI 卡片使用钉钉流式卡片呈现出打字机效果;普通消息只在 Agent 完成后发送最终结果。
174
+ - AI 卡片:需先在[卡片平台](https://open-dev.dingtalk.com/fe/card)创建「消息卡片 + 场景 AI 卡片」模板(在「输出中」状态的 Markdown 组件开启流式开关并绑定变量,默认 `content`;标题变量用 `title`;结束语变量用 `end_text`),并为应用申请 `Card.Streaming.Write`(投放/改标题还需 `Card.Instance.Write`)权限。在页面填入模板 ID 后即可启用;模板未配置、权限不足或接口报错时会自动回退为普通卡片。AI 卡片标题不带图标:处理中为 `【Pi】模型名 进行中...`,完成后为 `【Pi】完成 总耗时 15s`,暂停/失败为 `【Pi】处理暂停/处理失败 总耗时 Xs`;完成时的结束语(模型名、消息数、工具次数)写入 `end_text`,不拼进正文。AI 卡片单次内容建议不超过 1K、总量不超过 3K,超长回复会自动改用文本发送。
145
175
  - 处理详情:默认不显示,可在卡片设置中开启;开启后展示消息数和工具调用数。
146
176
  - 卡片更新间隔设为正数时按间隔更新处理中内容;设为 `0` 或负数时关闭处理中更新,仅在 Agent 完成后发送最终结果。
147
177
  - 暂停、开启/关闭监听、切换 Agent 的关键词。多个关键词用 `|` 分隔。
148
178
  - 可选的 Webhook,用于 Agent 处理失败时向群发送文本通知。
149
179
 
150
- 管理页首次启动默认密码为 `5552123`,登录后可在“机器人配置”页修改密码。密码使用 Node `scrypt` 哈希保存于 `~/.oh-my-im/dashboard-password.json`,不会写入公开配置;登录会话仅保存在内存中的 HttpOnly Cookie。修改密码后现有会话会失效。若把绑定地址改为 `0.0.0.0` 或 `::`,仍应在前面增加 HTTPS 反向代理或 VPN,不要直接暴露管理页。
180
+ 管理页首次启动默认密码为 `5552123`,登录后可在“控制台安全 → 系统密码”中修改。密码使用 Node `scrypt` 哈希保存于 `~/.oh-my-im/dashboard-password.json`,不会写入公开配置;登录会话用 HttpOnly Cookie 标识,服务端有效期与 Cookie `Max-Age` 均为 **60 天**,并持久化到 `~/.oh-my-im/dashboard-sessions.json`,因此重启看板不会掉线。修改密码会清空全部会话(所有设备下线)。若把绑定地址改为 `0.0.0.0` 或 `::`,仍应在前面增加 HTTPS 反向代理或 VPN,不要直接暴露管理页。
151
181
 
152
182
  ## 单聊命令
153
183
 
@@ -172,7 +202,7 @@ Session 超级管理员额外拥有:
172
202
  /admin-reset
173
203
  ```
174
204
 
175
- 用户的默认私有工作目录按钉钉用户 ID 隔离。普通 `/sessions` 和 `/use` 不会展示或切换到其他用户目录中的 Session;管理员切换只影响当前私聊。
205
+ 用户的私有工作目录按发送人名称隔离,例如 `~/.oh-my-im/users/杜振训`。普通 `/sessions` 和 `/use` 不会展示或切换到其他用户目录中的 Session;管理员切换只影响当前私聊。
176
206
 
177
207
  ## 群消息控制
178
208
 
@@ -180,14 +210,16 @@ Session 超级管理员额外拥有:
180
210
 
181
211
  群监听依赖 DWS 事件订阅和本地 DWS 登录状态。事件总线连接成功不等于目标群已生效,实际是否处理还取决于管理页中的群成员规则。
182
212
 
213
+ 由 AI 发送的群消息不会被 Agent 回复:系统会忽略群内所有机器人成员(如钉钉 AI 助手、其他 AI 机器人)发出的消息,也会忽略带「AI 发送」角标的消息,避免 AI 之间互相触发。机器人成员列表会在启动时和每 5 分钟自动刷新。
214
+
183
215
  ## 环境变量
184
216
 
185
217
  环境变量用于覆盖 CLI 路径、工作目录和部分运行参数;钉钉凭证和业务规则应在管理页中配置。
186
218
 
187
219
  | 变量 | 默认值 | 用途 |
188
220
  | --- | --- | --- |
189
- | `CODEX_WORK_DIR` | 启动目录 | 群侧 Codex 工作目录 |
190
- | `AGENT_WORK_DIR` | 启动目录 | 单聊 Agent 工作目录 |
221
+ | `CODEX_WORK_DIR` | `~/.oh-my-im/group/<群名>` | 群聊 Agent 工作目录(设置后为固定目录,不再拼接群名) |
222
+ | `AGENT_WORK_DIR` | `~/.oh-my-im/users/<发送人名称>` | 私聊 Agent 工作目录(设置后为固定目录,不再拼接人名) |
191
223
  | `DWS_CLI_PATH` | `dws` | DWS CLI 路径 |
192
224
  | `CODEX_CLI_PATH` | `codex` | 群侧 Codex CLI 路径 |
193
225
  | `PI_CLI_PATH` | `pi` | Pi CLI 路径 |
@@ -199,7 +231,7 @@ Session 超级管理员额外拥有:
199
231
  | `PI_CODING_AGENT_SESSION_DIR` | `~/.pi/agent/sessions` | Pi Session 根目录 |
200
232
  | `OHMIM_DATA_DIR` | `~/.oh-my-im` | 回复历史目录使用的数据根目录 |
201
233
 
202
- 模型来源:Codex 使用系统 CLI 默认模型,Pi 和 OpenCode 使用 Web 中各自的模型配置;未配置时分别使用对应 CLI 默认模型。OpenCode 模型列表来自 `opencode models`,保存值格式为 `provider/model`。
234
+ 模型来源:Codex 使用 `model_catalog_json` 目录里的模型或 CLI 默认模型,Pi 和 OpenCode 使用 Web 中各自的模型配置;未配置时分别使用对应 CLI 默认模型。OpenCode 模型列表来自 `opencode models`,保存值格式为 `provider/model`。
203
235
 
204
236
  当前实现默认以 bypass/full approval 方式运行 Agent。请只在可信的本地工作目录中使用,并确保 Agent 运行账号拥有合适的文件权限。
205
237
 
@@ -211,6 +243,8 @@ Session 超级管理员额外拥有:
211
243
  ~/.oh-my-im/
212
244
  ├── dws-dashboard.json # 管理页配置,包含敏感凭证
213
245
  ├── dws-dashboard-server.json # 管理页 host/port
246
+ ├── dashboard-password.json # 管理页密码(scrypt 哈希)
247
+ ├── dashboard-sessions.json # 已登录设备的会话(60 天有效)
214
248
  ├── omi-state.json # omi 管理的进程状态
215
249
  ├── omi.log # worker 合并日志
216
250
  ├── omi-bot.lock # 单聊 worker 锁
@@ -218,7 +252,9 @@ Session 超级管理员额外拥有:
218
252
  ├── omi-bot-status.json # 单聊连接状态
219
253
  ├── dws-cards.json # 群卡片状态
220
254
  ├── group-sessions.json # 群聊 Agent Session 绑定
255
+ ├── group-agents.json # 每个群当前选用的 Agent
221
256
  ├── private-sessions.json # 私聊 Agent Session 绑定
257
+ ├── private-agents.json # 每个私聊当前选用的 Agent
222
258
  ├── dws-history-cursor.json # 个人群历史轮询时间游标和消息去重键
223
259
  └── replies/YYYY-MM-DD.json # 单聊和群聊回复历史
224
260
  ```
@@ -247,7 +283,13 @@ npm run dev:dws
247
283
  tail -f ~/.oh-my-im/omi.log
248
284
  ```
249
285
 
250
- `package.json` 保留了 `npm test` 入口,但当前仓库没有 `tests/*.test.mjs` 文件;因此提交前至少应运行 `npm run build`,并结合实际 DWS、钉钉和 Agent 依赖做端到端验证。构建不会验证外部账号、权限、事件订阅或卡片发送能力。
286
+ `package.json` 提供 `npm test`,会先构建再运行 `tests/*.test.mjs`(Node 内置 test runner,覆盖 Markdown 表格转换、AI 卡片会话收尾/降级、钉钉卡片接口重试、模型名归一化)。提交前至少运行:
287
+
288
+ ```bash
289
+ npm test
290
+ ```
291
+
292
+ 单测不覆盖真实的外部依赖;发版前仍建议结合 DWS、钉钉和 Agent 做一次端到端验证,因为构建不会验证外部账号、权限、事件订阅或卡片发送能力。
251
293
 
252
294
  ## 常见问题
253
295
 
@@ -285,19 +327,34 @@ omi stop
285
327
 
286
328
  ```text
287
329
  src/
288
- ├── omi.ts # omi CLI、worker 生命周期和进程状态
289
- ├── dashboard-worker.ts # 管理页 worker
290
- ├── dws-dashboard.ts # 管理页 HTTP 服务和配置模型
291
- ├── group-worker.ts # DWS 群消息监听、队列和群卡片
292
- ├── bot-worker.ts # 单聊 worker 生命周期
293
- ├── bot-app.ts # 单聊鉴权、命令和 Agent 会话
294
- ├── dws-client.ts # DWS CLI 调用与 JSON 适配
295
- ├── dws-history.ts # 群消息历史补偿
296
- ├── dingtalk.ts # Stream 单聊消息解析与发送
297
- ├── dingtalk-card.ts # StandardCard 创建和更新
298
- ├── dingtalk-robot.ts # 钉钉机器人 OpenAPI/Webhook
299
- ├── agents/ # Codex/Pi/OpenCode 进程适配
300
- └── conversation-log.ts # 回复历史持久化
330
+ ├── omi.ts # omi CLI、worker 生命周期和进程状态
331
+ ├── dashboard-worker.ts # 管理页 worker(入口)
332
+ ├── group-worker.ts # DWS 群消息监听、队列和群卡片(入口)
333
+ ├── bot-worker.ts # 单聊 worker 生命周期(入口)
334
+ ├── bot-app.ts # 单聊鉴权、命令、Agent 会话与卡片编排
335
+ ├── dws-dashboard.ts # 管理页 HTTP 服务、页面和共享配置模型
336
+ ├── agents/ # Codex / Pi / OpenCode 进程适配
337
+ ├── index.ts # 统一入口:runAgent / listAgentSessions
338
+ ├── codex-agent.ts
339
+ ├── pi-agent.ts
340
+ ├── opencode-agent.ts
341
+ │ └── process-utils.ts
342
+ ├── dingtalk/ # 钉钉侧:消息、卡片、Markdown
343
+ │ ├── dingtalk.ts # Stream 单聊消息解析与发送
344
+ │ ├── dingtalk-card.ts # 普通互动卡片(StandardCard)
345
+ │ ├── dingtalk-ai-card.ts # AI 卡片(流式卡片)API 客户端
346
+ │ ├── ai-card.ts # AI 卡片会话:投放、流式、收尾与降级
347
+ │ ├── dingtalk-robot.ts # 钉钉机器人 OpenAPI / Webhook
348
+ │ └── markdown.ts # 钉钉 Markdown 归一化(表格转列表)
349
+ ├── dws/ # DWS CLI 侧
350
+ │ ├── dws-client.ts # DWS CLI 调用与 JSON 适配
351
+ │ └── dws-history.ts # 群消息历史补偿
352
+ └── core/ # 通用基础
353
+ ├── config.ts # 运行配置与 Agent 模型解析
354
+ ├── logger.ts # 日志
355
+ ├── version.ts # 版本号单一来源(读 package.json)
356
+ ├── conversation-log.ts # 回复历史持久化
357
+ └── monitor-command.ts # 群控制指令解析与配置变更
301
358
  ```
302
359
 
303
360
  ## License
@@ -3,7 +3,7 @@ import { open, readFile, readdir } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { createInterface } from "node:readline";
6
- import { createLogger } from "../logger.js";
6
+ import { createLogger } from "../core/logger.js";
7
7
  import { createAgentEnv } from "./process-utils.js";
8
8
  const log = createLogger("Codex");
9
9
  async function findCodexRollouts(root) {
@@ -66,7 +66,16 @@ export async function listCodexSessions(_config) {
66
66
  const sessions = await Promise.all(files.map(async (path) => {
67
67
  try {
68
68
  const events = (await readPrefix(path)).split(/\r?\n/).filter(Boolean)
69
- .map((line) => JSON.parse(line));
69
+ .flatMap((line) => {
70
+ try {
71
+ return [JSON.parse(line)];
72
+ }
73
+ catch {
74
+ // readPrefix 只读前 512KB,最后一行可能是被截断的半行;跳过它,
75
+ // 不要因为这一行丢掉整个 session。
76
+ return [];
77
+ }
78
+ });
70
79
  const meta = events.find((event) => event.type === "session_meta");
71
80
  const payload = meta?.payload;
72
81
  if (!meta || !payload)
@@ -170,6 +179,9 @@ function queueCodexMessage(cliPath, threadId, message, config) {
170
179
  }
171
180
  function buildArgs(prompt, workDir, sessionId, config) {
172
181
  const common = ["--json", "--skip-git-repo-check"];
182
+ // 管理页配置了 Codex 模型时显式传入;未配置则沿用 Codex CLI 自己的默认模型。
183
+ if (config.agentModel)
184
+ common.push("--model", config.agentModel);
173
185
  if (config.codexPermissionMode === "bypass") {
174
186
  common.push("--dangerously-bypass-approvals-and-sandbox");
175
187
  }
@@ -188,8 +200,8 @@ export function runCodex(prompt, sessionId, config, callbacks = {}) {
188
200
  const start = Date.now();
189
201
  const args = buildArgs(prompt, config.codexWorkDir, sessionId, config);
190
202
  const env = createAgentEnv(config.codexProxy);
191
- // Codex model selection belongs exclusively to the CLI config. Do not
192
- // let legacy application or model environment variables override it.
203
+ // Model selection is passed explicitly via --model; keep legacy model
204
+ // environment variables from overriding the CLI config.
193
205
  delete env.DWS_CODEX_MODEL;
194
206
  delete env.CODEX_MODEL;
195
207
  delete env.OPENAI_MODEL;
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { createLogger } from "../logger.js";
2
+ import { createLogger } from "../core/logger.js";
3
3
  import { asObject, attachJsonlReader, createOpenCodeEnv } from "./process-utils.js";
4
4
  const log = createLogger("OpenCode");
5
5
  function asTimestamp(value) {
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { open, readdir } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { createLogger } from "../logger.js";
5
+ import { createLogger } from "../core/logger.js";
6
6
  import { asObject, attachJsonlReader, createAgentEnv } from "./process-utils.js";
7
7
  const log = createLogger("Pi");
8
8
  async function findJsonlFiles(root) {
@@ -43,7 +43,15 @@ export async function listPiSessions(_config) {
43
43
  let title;
44
44
  let summary;
45
45
  for (const line of lines.slice(1)) {
46
- const event = JSON.parse(line);
46
+ let event;
47
+ try {
48
+ event = JSON.parse(line);
49
+ }
50
+ catch {
51
+ // readPrefix 只读前 128KB,最后一行可能是被截断的半行;
52
+ // 跳过它,不要因为这一行丢掉整个 session。
53
+ continue;
54
+ }
47
55
  const message = asObject(event.message);
48
56
  if (event.type !== "message" || !Array.isArray(message?.content))
49
57
  continue;