oh-my-im 0.1.9 → 0.1.11

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
@@ -75,9 +75,9 @@ omi
75
75
  http://127.0.0.1:12525
76
76
  ```
77
77
 
78
- 完成机器人凭证、授权人员和监听规则配置,然后点击“保存并生效”。配置修改会被运行中的监听器立即读取,一般不需要重启。
78
+ Web 管理页、DWS 群监听器和钉钉机器人单聊分别运行在独立进程中。即使 DWS 尚未登录、机器人凭证尚未配置或单聊授权人员为空,管理页也可以正常打开。完成机器人凭证、授权人员和监听规则配置,然后点击“保存并生效”。配置修改会被运行中的进程自动读取,一般不需要重启。
79
79
 
80
- > 单聊机器人需要有效的应用凭证和至少一名“机器人单聊授权人员”。如果首次启动时尚未配置,先让群监听器启动管理页并保存配置,再执行 `omi restart`。
80
+ > 单聊机器人在没有授权人员时会以 deny-all 模式启动,所有单聊消息都会被拒绝;在管理页添加授权人员后即可使用。
81
81
 
82
82
  ---
83
83
 
@@ -101,6 +101,7 @@ omi -h # 查看帮助
101
101
  omi
102
102
  ├─ 检查 ~/.oh-my-im/omi-state.json
103
103
  ├─ 检查是否已有未受 omi 管理的群监听进程
104
+ ├─ 后台启动 dashboard-worker(本地管理页)
104
105
  ├─ 后台启动 dws-listener(默认模式)
105
106
  ├─ 后台启动 bot-worker
106
107
  ├─ 将 stdout/stderr 写入 ~/.oh-my-im/omi.log
@@ -8,6 +8,7 @@ const dataDir = join(homedir(), ".oh-my-im");
8
8
  const configFile = join(dataDir, "dws-dashboard.json");
9
9
  const serverFile = join(dataDir, "dws-dashboard-server.json");
10
10
  const botStatusFile = join(dataDir, "omi-bot-status.json");
11
+ const packageFile = join(new URL(".", import.meta.url).pathname, "..", "package.json");
11
12
  const repliesDir = join(dataDir, "replies");
12
13
  const defaultConfig = () => ({
13
14
  privateChatEnabled: true,
@@ -83,6 +84,11 @@ async function botStatus() {
83
84
  }
84
85
  }
85
86
  async function main() {
87
+ let version = "unknown";
88
+ try {
89
+ version = JSON.parse(await readFile(packageFile, "utf8")).version?.trim() || version;
90
+ }
91
+ catch { /* package metadata is optional in development */ }
86
92
  // Do not create a partial config here. The listener owns initialization and
87
93
  // migration; this process only reads it and writes complete configurations
88
94
  // submitted by the dashboard.
@@ -114,7 +120,7 @@ async function main() {
114
120
  searchGroups, listGroupMembers, searchUsers, searchBots,
115
121
  getCurrentDwsUser: async () => ({ ...(await getCurrentDwsUser()), auth: await getDwsAuthStatus() }),
116
122
  getDwsAuthStatus, startDwsDeviceLogin, getDwsDeviceLoginOutput, logoutDws, getBotStatus: botStatus,
117
- }, { host: serverConfig.host });
123
+ }, { host: serverConfig.host, version });
118
124
  console.log(`[OmiDashboard] dashboard started at http://${serverConfig.host}:${serverConfig.port}`);
119
125
  }
120
126
  main().catch((err) => {
@@ -119,7 +119,7 @@ export function startDashboard(port, hooks, options = {}) {
119
119
  }
120
120
  if (request.method === "GET" && url.pathname === "/") {
121
121
  response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
122
- response.end(PAGE);
122
+ response.end(PAGE.replaceAll("__OMI_VERSION__", options.version ? `v${options.version.replace(/^v/i, "")}` : "unknown"));
123
123
  return;
124
124
  }
125
125
  if (request.method === "GET" && url.pathname === "/favicon.ico") {
@@ -245,7 +245,7 @@ const PAGE = String.raw `<!doctype html><html lang="zh-CN"><head><meta charset="
245
245
  :root{--nav:#172033;--nav-muted:#9ba8bb;--line:#dfe5ed;--blue:#1769e0;--canvas:#f5f6f8}body{background:var(--canvas);color:#172033}.wrap{max-width:none;min-height:100vh;padding:0}.app-shell{display:grid;grid-template-columns:232px minmax(0,1fr);min-height:100vh}.app-sidebar{position:fixed;inset:0 auto 0 0;width:232px;z-index:30;display:flex;flex-direction:column;padding:14px 12px;background:var(--nav);color:#fff}.brand{display:flex;align-items:center;gap:11px;padding:0 6px 16px}.brand-mark{display:grid;place-items:center;width:34px;height:34px;border:1px solid #48566b;border-radius:7px;background:#222d3e;color:#fff;font:700 14px ui-monospace,SFMono-Regular,Menlo,monospace}.brand-icon{object-fit:cover}.brand-name{font-size:15px;font-weight:700;letter-spacing:.01em}.brand-sub{margin-top:2px;color:var(--nav-muted);font-size:11px}.nav-label{padding:0 10px 8px;color:#718096;font-size:10px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}.app-nav{display:flex;flex-direction:column;gap:3px}.nav-item{display:flex;align-items:center;gap:10px;width:100%;min-height:42px;padding:0 11px;border:0;border-radius:6px;background:transparent;color:#b9c3d1;text-align:left;font:13px inherit;cursor:pointer}.nav-item:hover{background:#202c3e;color:#fff}.nav-item.active{background:#2a3850;color:#fff;font-weight:600}.nav-index{width:22px;color:#718096;font:11px ui-monospace,SFMono-Regular,Menlo,monospace}.nav-item.active .nav-index{color:#78aaf8}.current-user-card{margin-top:18px;padding:12px 14px;border:1px solid #dfe6ef;border-radius:6px;background:#f8fafc}.current-user-card .user-value{margin-top:4px;color:#172033;font-size:14px;font-weight:600}.current-user-card .user-id{margin-top:3px;color:#657287;font:11px ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}.sidebar-foot{margin-top:auto;padding:18px 8px 0;border-top:1px solid #2e3a4d;color:#8996a9;font-size:11px;line-height:1.6}.sidebar-foot .version{display:inline-block;margin-top:5px;color:#c0cad8;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.sidebar-foot a{color:#8996a9;text-decoration:none;word-break:break-all}.sidebar-foot a:hover{color:#fff;text-decoration:underline}.app-main{grid-column:2;min-width:0}.app-topbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;justify-content:space-between;min-height:58px;padding:8px 24px;border-bottom:1px solid var(--line);background:rgba(255,255,255,.94);backdrop-filter:blur(12px)}.topbar-copy h1{margin:0;color:#152033;font-size:21px;line-height:1.3}.topbar-copy p{margin:4px 0 0;color:#6b7789;font-size:12px}.topbar-actions{display:flex;align-items:center;gap:12px}.status{display:inline-flex;align-items:center;gap:7px;height:32px;padding:0 10px;border-color:#dbe2eb;border-radius:5px;background:#fff;color:#5c6878;font-size:12px}.status::before{content:'';width:7px;height:7px;border-radius:50%;background:#98a2b3}.status.connected::before{background:#20a064;box-shadow:0 0 0 3px #e1f5ea}.status.stopped::before,.status.error::before{background:#d14b45}.menu-button{display:none;width:40px;height:40px;border:1px solid var(--line);border-radius:6px;background:#fff;color:#223048;font-size:20px}.app-content{width:100%;padding:10px 24px 100px}.app-page{display:none}.app-page.active{display:block}.app-page>.panel{margin:0;border:1px solid var(--line);border-radius:8px;background:#fff;box-shadow:0 1px 2px rgba(16,24,40,.03)}.app-page>.panel>h2{display:none}.agent-settings .agent-row{max-width:none}.agent-options{padding:10px 0}.agent-options label{min-width:150px;padding:13px 15px;border:1px solid #dbe2eb;border-radius:6px;background:#fafbfc}.agent-options label:has(input:checked){border-color:#7daaf0;background:#f0f6ff;color:#174f9f}.agent-command-grid{display:flex;flex-direction:column;gap:14px;margin-top:20px}.agent-command-grid .field{width:100%}.command-settings:not(.expanded) .command-grid,.rules-panel:not(.expanded) .rules-content,.replies-panel:not(.expanded) #replies,.collapsible-panel:not(.expanded)>.collapsible-content{display:initial}.command-settings .command-grid,.rules-panel .rules-content,.replies-panel #replies,.collapsible-panel>.collapsible-content{margin-top:0!important}.robot-settings .robot-row{grid-template-columns:repeat(2,minmax(0,1fr))}.prompt-settings textarea{min-height:360px}.binding-commands{border-color:#dfe6ef;background:#fafbfc}.rules-panel{margin:0}.rule{padding:18px 0}.save-floating{position:fixed;right:auto;bottom:24px;left:calc(232px + (100vw - 232px)/2);z-index:40;min-width:148px;height:42px;padding:0 22px;border-radius:6px;background:#1769e0;box-shadow:0 8px 20px rgba(23,105,224,.24);transform:translateX(-50%)}.save-floating:hover{background:#105dc9}.replies-panel{margin:0!important}.reply{border:1px solid #dfe5ed!important;border-radius:7px!important}.reply .meta{border:0;border-bottom:1px solid #e8edf3;border-radius:0;background:#f8fafc;box-shadow:none;color:#344054;font-size:13px}.mobile-scrim{display:none}.notice{bottom:78px}.field input,.field select,.command-grid input,.prompt-settings textarea{border-color:#ccd5e1;border-radius:5px}.field input:focus,.field select:focus,.command-grid input:focus,.prompt-settings textarea:focus{outline:2px solid #c8dcfb;border-color:#4f88dd}.button,.search{border-radius:5px}.secondary{background:#edf4ff;color:#175bb8}.page-agent .panel,.page-prompt .panel,.page-command .panel,.page-robot .panel{padding:26px}.section-toggle::after,.rules-toggle::after,.command-toggle::after,.replies-toggle::after{display:none}
246
246
  @media(max-width:820px){.app-shell{display:block}.app-sidebar{width:min(82vw,280px);padding-top:18px;transform:translateX(-105%);transition:transform .2s ease;box-shadow:16px 0 40px rgba(17,24,39,.18)}body.nav-open .app-sidebar{transform:translateX(0)}.mobile-scrim{position:fixed;inset:0;z-index:25;display:block;background:rgba(15,23,42,.42);opacity:0;pointer-events:none;transition:opacity .2s}body.nav-open .mobile-scrim{opacity:1;pointer-events:auto}.app-main{min-height:100vh}.app-topbar{min-height:70px;padding:12px 16px}.menu-button{display:block;order:-1}.topbar-actions{gap:8px}.topbar-copy{flex:1;min-width:0}.topbar-copy h1{font-size:18px}.topbar-copy p{display:none}.status{max-width:120px;overflow:hidden;white-space:nowrap}.app-content{padding:20px 14px 100px}.app-page>.panel,.page-agent .panel,.page-prompt .panel,.page-command .panel,.page-robot .panel{padding:16px}.robot-settings .robot-row,.command-grid{grid-template-columns:1fr}.agent-command-grid{display:flex}.prompt-settings textarea{min-height:300px}.agent-options{display:grid;grid-template-columns:1fr 1fr;gap:8px}.agent-options label{min-width:0;justify-content:center;padding:11px 8px}.agent-options input[type=radio]{width:18px!important;height:18px!important;min-width:18px!important;min-height:18px!important;padding:0!important}.save-floating{right:14px;bottom:14px;left:14px;width:calc(100% - 28px);height:48px;transform:none}.notice{bottom:72px}.binding-commands{padding:12px}.rule-grid,.configured-grid{grid-template-columns:1fr}.members{grid-column:1}.rule{padding-right:38px}.toolbar{grid-template-columns:1fr}.toolbar .format{grid-column:1}.member-list{max-height:260px}.line{width:100%}}
247
247
  </style></head><body><main class="wrap"><header><div><h1>oh-my-im 控制台</h1><p>保存后立即生效。搜索并选择群,再从该群成员中选择需要监听的人。</p></div><div class="status" id="connection">加载中</div></header><section class="panel agent-settings"><h2>Agent 引擎设置</h2><div class="agent-row"><div class="field"><label>当前使用的 Agent</label><div class="agent-options"><label><input type="radio" name="agent" value="codex" checked> Codex Agent</label><label><input type="radio" name="agent" value="pi"> Pi Agent</label></div></div></div><div class="command-grid agent-command-grid"><div class="field"><label>切换到 Codex 指令</label><input type="text" id="keywordsSwitchCodex" placeholder="例如:切codex|切换codex"></div><div class="field"><label>切换到 Pi 指令</label><input type="text" id="keywordsSwitchPi" placeholder="例如:切pi|切换pi|换pi"></div></div></section><section class="panel dws-settings"><h2>DWS 配置</h2><div class="dws-actions"><button class="button secondary" id="dwsDeviceLogin">使用 Device Flow 登录</button><button class="button danger-button" id="dwsLogout">退出 DWS 登录</button></div><div id="dwsDevicePanel" class="dws-device-panel"><div class="dws-device-head"><strong>Device Flow 登录信息</strong><button class="button secondary" id="dwsDeviceCopy">复制登录信息</button></div><div id="dwsDeviceOutput" class="dws-device-output"></div><div class="dws-device-tip">请在其他电脑或手机打开上面的 URL,完成钉钉授权。登录信息保存在运行 DWS 的服务器上。</div></div><div class="dws-status-box"><div class="dws-status-loading">正在读取 dws auth status...</div><div id="dwsAuthFields" class="dws-auth-fields"><div><span>企业</span><strong id="dwsCorpName">-</strong></div><div><span>账号</span><strong id="dwsUserName">-</strong></div><div><span>user_id</span><code id="dwsUserId">-</code></div><div><span>Token 过期</span><strong id="dwsExpiresAt">-</strong></div><div><span>Refresh Token 过期</span><strong id="dwsRefreshExpiresAt">-</strong></div></div><pre id="dwsAuthStatus" hidden></pre><div id="dwsLoginGuide" class="dws-login-guide"></div></div><p class="dws-help">信息来自本机 <code>dws auth status</code>。登录与退出操作都在运行 DWS 的机器上执行。</p></section><section class="panel prompt-settings collapsible-panel"><h2 class="section-toggle">提示词前缀</h2><div class="collapsible-content"><div class="field"><label>提示词前缀</label><textarea id="groupPromptPrefix" placeholder="可留空。该内容会放在群消息事件 JSON 之前。"></textarea><div class="prompt-help">保存后立即用于新的群消息批次;程序只会在末尾附加“DingTalk 消息事件”和事件 JSON。</div></div></div></section><section class="panel private-chat-settings"><h2>个人与机器人私聊</h2><div class="private-chat-row"><div><label>个人与机器人私聊</label><p>关闭后会断开单聊 Stream 长连接;开启后自动重新连接。</p></div><label class="switch"><input type="checkbox" id="privateChatEnabled"><span></span></label><strong id="privateChatStatus">查询中...</strong></div></section><section class="history-settings"><h2>历史消息设置</h2><div class="history-config"><div class="history-grid"><div class="field"><label>拉取群数量</label><input type="number" id="historyGroupLimit" min="1" max="500" step="1" placeholder="10"></div><div class="field"><label>每群消息数量</label><input type="number" id="historyMessageLimit" min="1" max="500" step="1" placeholder="20"></div><div class="field"><label>拉取间隔(秒)</label><input type="number" id="historyPollIntervalSeconds" min="0" max="3600" step="1" placeholder="5"><div class="field-help">小于等于 0 时不拉取历史消息。</div></div></div></div></section><section class="panel robot-settings collapsible-panel"><h2 class="section-toggle">机器人设置</h2><div class="robot-row collapsible-content"></div></section><section class="panel command-settings"><h2 class="command-toggle">任务指令关键词</h2><div class="command-grid"><div class="field"><label>暂停当前任务</label><input type="text" id="keywordsPause" placeholder="例如:停|暂停|停止当前任务"></div></div></section><section class="summary"><div class="metric"><small>监听规则</small><strong id="targetCount">-</strong></div><div class="metric"><small>处理中批次</small><strong id="activeBatches">-</strong></div><div class="metric"><small>最后收到消息</small><strong id="lastEvent">-</strong></div></section><button class="button save-floating" id="save">保存并生效</button><div class="notice" id="notice" role="status" aria-live="polite"></div><div class="grid"><section class="panel rules-panel"><h2 class="rules-toggle">钉钉群监控规则</h2><div class="rules-content"><div class="binding-commands"><h3>绑定指令</h3><div class="binding-help">授权人员在包含配置机器人的群中发送以下关键词,可绑定或解绑“当前群 + 当前发送人”的监听规则。</div><div class="command-grid"><div class="field"><label>打开群监听</label><input type="text" id="keywordsMonitorOpen" placeholder="例如:打开ai|启动ai|醒醒"></div><div class="field"><label>关闭群监听</label><input type="text" id="keywordsMonitorStop" placeholder="例如:停止ai|关闭ai|睡吧"></div></div></div><div id="rules"></div><div class="toolbar"><button class="button secondary" id="add">添加监听规则</button><div class="format"><span>回复格式</span><label><input type="radio" name="format" value="markdown"> Markdown</label><label><input type="radio" name="format" value="plain"> 纯文本</label></div></div></div></section><section class="panel replies-panel"><h2 class="replies-toggle">Agent 最近回复</h2><div id="replies" class="empty">暂无回复</div></section></div></main><script>
248
- let selectedBotUsers=[],selectedSuperAdminIds=[];const agentSelect=()=>document.querySelector('input[name=agent]:checked'),rules=document.querySelector('#rules'),notice=document.querySelector('#notice'),replyList=document.querySelector('#replies'),layout=document.querySelector('.grid'),rulesPanel=layout.firstElementChild,repliesPanel=layout.lastElementChild,agentSettings=document.querySelector('.agent-settings'),promptSettings=document.querySelector('.prompt-settings'),robotSettings=document.querySelector('.robot-settings'),privateChatSettings=document.querySelector('.private-chat-settings'),commandSettings=document.querySelector('.command-settings'),root=document.querySelector('.wrap'),connection=document.querySelector('#connection'),saveControl=document.querySelector('#save');document.querySelector('#add').textContent='添加钉钉规则';const renderDwsDeviceOutput=(text)=>{const output=document.querySelector('#dwsDeviceOutput');if(!output)return;output.replaceChildren();const parts=String(text||'暂无登录信息').split(/(https?:\/\/[^\s]+)/g);parts.forEach(part=>{if(/^https?:\/\//.test(part)){const a=document.createElement('a');a.href=part.replace(/[),.;。!]$/,'');a.target='_blank';a.rel='noreferrer';a.textContent=part;output.append(a)}else output.append(document.createTextNode(part))})};const pollDwsDeviceLogin=async()=>{try{const r=await fetch('/api/dws-device-login',{cache:'no-store'}),b=await r.json();if(r.ok){renderDwsDeviceOutput(b.output);document.querySelector('#dwsDevicePanel')?.classList.add('visible');if(b.running)setTimeout(pollDwsDeviceLogin,1000);else void loadDwsAuth()}}catch(e){renderDwsDeviceOutput(e instanceof Error?e.message:String(e))}};const loadPrivateChatStatus=async()=>{try{const r=await fetch('/api/bot-status',{cache:'no-store'}),b=await r.json();if(!r.ok)throw new Error(b.error||'私聊机器人状态查询失败');const enabled=document.querySelector('#privateChatEnabled'),status=document.querySelector('#privateChatStatus');enabled.checked=b.status?.enabled===true;status.textContent=b.status?.connected?'已连接':'未连接'}catch(e){document.querySelector('#privateChatStatus').textContent='查询失败'}};document.querySelector('#privateChatEnabled').onchange=async event=>{const enabled=event.target.checked;try{const stateResponse=await fetch('/api/state',{cache:'no-store'}),state=await stateResponse.json();if(!stateResponse.ok||!state.config)throw new Error('读取当前配置失败');const r=await fetch('/api/config',{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify({...state.config,privateChatEnabled:enabled,clientSecret:state.config.clientSecret||''})});const body=await r.json().catch(()=>({}));if(!r.ok||body.saved!==true)throw new Error(body.error||'保存个人私聊开关失败');await loadPrivateChatStatus()}catch(e){event.target.checked=!enabled;alert(e instanceof Error?e.message:String(e))}};setInterval(()=>void loadPrivateChatStatus(),2000);const loadDwsAuth=async()=>{const output=document.querySelector('#dwsAuthStatus'),loading=document.querySelector('.dws-status-loading'),guide=document.querySelector('#dwsLoginGuide');try{const r=await fetch('/api/dws-auth-status',{cache:'no-store'}),b=await r.json();if(!r.ok)throw new Error(b.error||'DWS 登录状态查询失败');if(loading)loading.remove();const status=b.status||{};const set=(id,value)=>{const el=document.querySelector('#'+id);if(el)el.textContent=value||'-'};set('dwsCorpName',status.corp_name);set('dwsUserName',status.user_name);set('dwsUserId',status.user_id);set('dwsExpiresAt',status.expires_at);set('dwsRefreshExpiresAt',status.refresh_expires_at);if(output)output.textContent='';if(b.status?.authenticated!==true&&guide)guide.innerHTML='当前未登录 DWS。请在运行 DWS 的机器执行 <code>dws auth login --device</code>,或点击“使用 Device Flow 登录”。'}catch(e){if(loading)loading.textContent='DWS 登录状态读取失败';if(output)output.textContent=e instanceof Error?e.message:String(e);if(guide)guide.innerHTML='请在运行 DWS 的机器执行 <code>dws auth login --device</code> 完成跨设备登录。'}};const dwsLoginButton=document.querySelector('#dwsDeviceLogin');dwsLoginButton.onclick=async()=>{dwsLoginButton.disabled=true;try{const r=await fetch('/api/dws-device-login',{method:'POST'}),b=await r.json();if(!r.ok)throw new Error(b.error||'DWS 登录启动失败');renderDwsDeviceOutput(b.message);document.querySelector('#dwsDevicePanel')?.classList.add('visible');void pollDwsDeviceLogin()}catch(e){renderDwsDeviceOutput(e instanceof Error?e.message:String(e));document.querySelector('#dwsDevicePanel')?.classList.add('visible')}finally{dwsLoginButton.disabled=false}};document.querySelector('#dwsDeviceCopy').onclick=async()=>{const text=document.querySelector('#dwsDeviceOutput')?.textContent||'';try{await navigator.clipboard.writeText(text);notice.textContent='登录信息已复制';setTimeout(()=>notice.textContent='',2000)}catch{notice.textContent='复制失败,请手动选择上方登录信息复制';}};document.querySelector('#dwsLogout').onclick=async()=>{if(!confirm('确认退出当前 DWS 登录吗?'))return;try{const r=await fetch('/api/dws-logout',{method:'POST'}),b=await r.json();if(!r.ok)throw new Error(b.error||'DWS 退出失败');await loadDwsAuth()}catch(e){alert(e instanceof Error?e.message:String(e))}};void loadDwsAuth();void loadPrivateChatStatus();robotSettings.querySelector('.robot-row').innerHTML='<div class="field"><label>机器人名称 <a class="platform-link" href="https://open-dev.dingtalk.com/fe/app" target="_blank" rel="noreferrer">(开放平台)</a></label><input id="robotName" placeholder="例如:我的 AI 助手"></div><div class="field bot-identity-field"><label>机器人 openDingTalkId</label><div class="line"><input id="robotSenderOpenDingTalkId" placeholder="点击“获取 ID”自动填写" readonly><button class="search" id="botSearchButton" type="button">获取 ID</button></div><div id="botSearchResult" class="bot-user-results"></div></div><div class="field"><label>钉钉应用 Client ID</label><input id="clientId" placeholder="ding..."></div><div class="field"><label>钉钉应用 Client Secret</label><input id="clientSecret" type="password" autocomplete="new-password" placeholder="已配置时留空不修改"></div><div class="field"><label>卡片标题显示总耗时</label><label class="check-field"><input type="checkbox" id="showElapsed"> 显示任务总耗时</label></div><div class="field"><label>卡片更新间隔(秒)</label><input type="number" id="cardUpdateIntervalMs" min="0.5" max="60" step="0.5" placeholder="3"></div><div class="field"><label>机器人单聊授权人员</label><div class="line"><input id="botUserSearch" placeholder="搜索钉钉真实姓名"><button class="search" id="botUserSearchButton" type="button">搜索</button></div><div id="botUserResults" class="bot-user-results"></div><div id="botUserSelected" class="bot-user-selected"></div></div><div class="field super-admin-field"><label>Session 超级管理员</label><div class="super-admin-help">仅限已加入单聊授权人员的用户。超级管理员可以按目录查看和切换本机 Session。</div><div id="superAdminSelected" class="bot-user-results super-admin-list"></div></div>';[promptSettings,robotSettings,commandSettings,rulesPanel,repliesPanel].forEach(panel=>panel.classList.add('expanded'));const historySettings=document.querySelector('.history-settings');const robotPage=document.createElement('div');robotPage.append(robotSettings,privateChatSettings,historySettings);const pageDefs=[['robot','机器人与权限','配置钉钉应用、卡片机器人和单聊授权人员。',robotPage],['dws','DWS 配置','查看当前本机 DWS 登录账号信息。',document.querySelector('.dws-settings')],['agent','Agent 引擎','选择默认执行引擎,并配置群内切换指令。',agentSettings],['prompt','提示词前缀','定义发送给 Agent 的群消息前缀,事件 JSON 将自动附加。',promptSettings],['commands','任务控制','配置暂停当前任务的消息指令。',commandSettings],['rules','群监控规则','管理绑定指令以及需要监听的群和人员。',rulesPanel],['replies','Agent回复','查看最近的群聊与单聊 Agent 处理记录。',repliesPanel]];root.replaceChildren();const shell=document.createElement('div');shell.className='app-shell';const sidebar=document.createElement('aside');sidebar.className='app-sidebar';sidebar.innerHTML='<div class="brand"><img class="brand-mark brand-icon" src="/assets/omi-icon.png" alt="omi" /><div><div class="brand-name">oh-my-im</div><div class="brand-sub">DingTalk Agent Console</div></div></div><div class="nav-label">Workspace</div><nav class="app-nav"></nav><div class="sidebar-foot">版本:<span class="version">v0.1.4</span> · <a href="https://github.com/duzhenxun/oh-my-im" target="_blank" rel="noreferrer">开源项目</a></div>';const main=document.createElement('div');main.className='app-main';const topbar=document.createElement('header');topbar.className='app-topbar';topbar.innerHTML='<button class="menu-button" type="button" aria-label="打开菜单">☰</button><div class="topbar-copy"><h1></h1><p></p></div>';const content=document.createElement('div');content.className='app-content';pageDefs.forEach(([id,title,description,panel],index)=>{const nav=document.createElement('button');nav.type='button';nav.className='nav-item'+(index===0?' active':'');nav.dataset.page=id;nav.innerHTML='<span class="nav-index">0'+(index+1)+'</span><span>'+title+'</span>';sidebar.querySelector('.app-nav').append(nav);const page=document.createElement('section');page.className='app-page page-'+id+(index===0?' active':'');page.dataset.page=id;page.append(panel);content.append(page)});const scrim=document.createElement('div');scrim.className='mobile-scrim';main.append(topbar,content);shell.append(sidebar,main);root.append(shell,scrim,notice,saveControl);const desktopMedia=window.matchMedia('(min-width:821px)');const syncViewport=()=>{if(desktopMedia.matches)document.body.classList.remove('nav-open')};desktopMedia.addEventListener?.('change',syncViewport);syncViewport();const showPage=id=>{document.querySelectorAll('.nav-item').forEach(item=>item.classList.toggle('active',item.dataset.page===id));document.querySelectorAll('.app-page').forEach(page=>page.classList.toggle('active',page.dataset.page===id));const def=pageDefs.find(item=>item[0]===id)||pageDefs[0];topbar.querySelector('h1').textContent=def[1];topbar.querySelector('p').textContent=def[2];saveControl.style.display=id==='replies'?'none':'';if(location.hash!=='#'+id)history.replaceState(null,'','#'+id);document.body.classList.remove('nav-open');window.scrollTo({top:0,behavior:'instant'})};sidebar.querySelectorAll('.nav-item').forEach(item=>item.onclick=()=>showPage(item.dataset.page));topbar.querySelector('.menu-button').onclick=()=>document.body.classList.toggle('nav-open');scrim.onclick=()=>document.body.classList.remove('nav-open');const initialPage=pageDefs.some(item=>item[0]===location.hash.slice(1))?location.hash.slice(1):'robot';window.addEventListener('hashchange',()=>{const id=location.hash.slice(1);if(pageDefs.some(item=>item[0]===id))showPage(id)});showPage(initialPage);replyList.style.maxHeight='min(680px,calc(100vh - 230px))';replyList.style.overflowY='auto';replyList.style.display='flex';replyList.style.flexDirection='column';replyList.style.gap='12px';let loaded=false;
248
+ let selectedBotUsers=[],selectedSuperAdminIds=[];const agentSelect=()=>document.querySelector('input[name=agent]:checked'),rules=document.querySelector('#rules'),notice=document.querySelector('#notice'),replyList=document.querySelector('#replies'),layout=document.querySelector('.grid'),rulesPanel=layout.firstElementChild,repliesPanel=layout.lastElementChild,agentSettings=document.querySelector('.agent-settings'),promptSettings=document.querySelector('.prompt-settings'),robotSettings=document.querySelector('.robot-settings'),privateChatSettings=document.querySelector('.private-chat-settings'),commandSettings=document.querySelector('.command-settings'),root=document.querySelector('.wrap'),connection=document.querySelector('#connection'),saveControl=document.querySelector('#save');document.querySelector('#add').textContent='添加钉钉规则';const renderDwsDeviceOutput=(text)=>{const output=document.querySelector('#dwsDeviceOutput');if(!output)return;output.replaceChildren();const parts=String(text||'暂无登录信息').split(/(https?:\/\/[^\s]+)/g);parts.forEach(part=>{if(/^https?:\/\//.test(part)){const a=document.createElement('a');a.href=part.replace(/[),.;。!]$/,'');a.target='_blank';a.rel='noreferrer';a.textContent=part;output.append(a)}else output.append(document.createTextNode(part))})};const pollDwsDeviceLogin=async()=>{try{const r=await fetch('/api/dws-device-login',{cache:'no-store'}),b=await r.json();if(r.ok){renderDwsDeviceOutput(b.output);document.querySelector('#dwsDevicePanel')?.classList.add('visible');if(b.running)setTimeout(pollDwsDeviceLogin,1000);else void loadDwsAuth()}}catch(e){renderDwsDeviceOutput(e instanceof Error?e.message:String(e))}};const loadPrivateChatStatus=async()=>{try{const r=await fetch('/api/bot-status',{cache:'no-store'}),b=await r.json();if(!r.ok)throw new Error(b.error||'私聊机器人状态查询失败');const enabled=document.querySelector('#privateChatEnabled'),status=document.querySelector('#privateChatStatus');enabled.checked=b.status?.enabled===true;status.textContent=b.status?.connected?'已连接':'未连接'}catch(e){document.querySelector('#privateChatStatus').textContent='查询失败'}};document.querySelector('#privateChatEnabled').onchange=async event=>{const enabled=event.target.checked;try{const stateResponse=await fetch('/api/state',{cache:'no-store'}),state=await stateResponse.json();if(!stateResponse.ok||!state.config)throw new Error('读取当前配置失败');const r=await fetch('/api/config',{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify({...state.config,privateChatEnabled:enabled,clientSecret:state.config.clientSecret||''})});const body=await r.json().catch(()=>({}));if(!r.ok||body.saved!==true)throw new Error(body.error||'保存个人私聊开关失败');await loadPrivateChatStatus()}catch(e){event.target.checked=!enabled;alert(e instanceof Error?e.message:String(e))}};setInterval(()=>void loadPrivateChatStatus(),2000);const loadDwsAuth=async()=>{const output=document.querySelector('#dwsAuthStatus'),loading=document.querySelector('.dws-status-loading'),guide=document.querySelector('#dwsLoginGuide');try{const r=await fetch('/api/dws-auth-status',{cache:'no-store'}),b=await r.json();if(!r.ok)throw new Error(b.error||'DWS 登录状态查询失败');if(loading)loading.remove();const status=b.status||{};const set=(id,value)=>{const el=document.querySelector('#'+id);if(el)el.textContent=value||'-'};set('dwsCorpName',status.corp_name);set('dwsUserName',status.user_name);set('dwsUserId',status.user_id);set('dwsExpiresAt',status.expires_at);set('dwsRefreshExpiresAt',status.refresh_expires_at);if(output)output.textContent='';if(b.status?.authenticated!==true&&guide)guide.innerHTML='当前未登录 DWS。请在运行 DWS 的机器执行 <code>dws auth login --device</code>,或点击“使用 Device Flow 登录”。'}catch(e){if(loading)loading.textContent='DWS 登录状态读取失败';if(output)output.textContent=e instanceof Error?e.message:String(e);if(guide)guide.innerHTML='请在运行 DWS 的机器执行 <code>dws auth login --device</code> 完成跨设备登录。'}};const dwsLoginButton=document.querySelector('#dwsDeviceLogin');dwsLoginButton.onclick=async()=>{dwsLoginButton.disabled=true;try{const r=await fetch('/api/dws-device-login',{method:'POST'}),b=await r.json();if(!r.ok)throw new Error(b.error||'DWS 登录启动失败');renderDwsDeviceOutput(b.message);document.querySelector('#dwsDevicePanel')?.classList.add('visible');void pollDwsDeviceLogin()}catch(e){renderDwsDeviceOutput(e instanceof Error?e.message:String(e));document.querySelector('#dwsDevicePanel')?.classList.add('visible')}finally{dwsLoginButton.disabled=false}};document.querySelector('#dwsDeviceCopy').onclick=async()=>{const text=document.querySelector('#dwsDeviceOutput')?.textContent||'';try{await navigator.clipboard.writeText(text);notice.textContent='登录信息已复制';setTimeout(()=>notice.textContent='',2000)}catch{notice.textContent='复制失败,请手动选择上方登录信息复制';}};document.querySelector('#dwsLogout').onclick=async()=>{if(!confirm('确认退出当前 DWS 登录吗?'))return;try{const r=await fetch('/api/dws-logout',{method:'POST'}),b=await r.json();if(!r.ok)throw new Error(b.error||'DWS 退出失败');await loadDwsAuth()}catch(e){alert(e instanceof Error?e.message:String(e))}};void loadDwsAuth();void loadPrivateChatStatus();robotSettings.querySelector('.robot-row').innerHTML='<div class="field"><label>机器人名称 <a class="platform-link" href="https://open-dev.dingtalk.com/fe/app" target="_blank" rel="noreferrer">(开放平台)</a></label><input id="robotName" placeholder="例如:我的 AI 助手"></div><div class="field bot-identity-field"><label>机器人 openDingTalkId</label><div class="line"><input id="robotSenderOpenDingTalkId" placeholder="点击“获取 ID”自动填写" readonly><button class="search" id="botSearchButton" type="button">获取 ID</button></div><div id="botSearchResult" class="bot-user-results"></div></div><div class="field"><label>钉钉应用 Client ID</label><input id="clientId" placeholder="ding..."></div><div class="field"><label>钉钉应用 Client Secret</label><input id="clientSecret" type="password" autocomplete="new-password" placeholder="已配置时留空不修改"></div><div class="field"><label>卡片标题显示总耗时</label><label class="check-field"><input type="checkbox" id="showElapsed"> 显示任务总耗时</label></div><div class="field"><label>卡片更新间隔(秒)</label><input type="number" id="cardUpdateIntervalMs" min="0.5" max="60" step="0.5" placeholder="3"></div><div class="field"><label>机器人单聊授权人员</label><div class="line"><input id="botUserSearch" placeholder="搜索钉钉真实姓名"><button class="search" id="botUserSearchButton" type="button">搜索</button></div><div id="botUserResults" class="bot-user-results"></div><div id="botUserSelected" class="bot-user-selected"></div></div><div class="field super-admin-field"><label>Session 超级管理员</label><div class="super-admin-help">仅限已加入单聊授权人员的用户。超级管理员可以按目录查看和切换本机 Session。</div><div id="superAdminSelected" class="bot-user-results super-admin-list"></div></div>';[promptSettings,robotSettings,commandSettings,rulesPanel,repliesPanel].forEach(panel=>panel.classList.add('expanded'));const historySettings=document.querySelector('.history-settings');const robotPage=document.createElement('div');robotPage.append(robotSettings,privateChatSettings,historySettings);const pageDefs=[['robot','机器人与权限','配置钉钉应用、卡片机器人和单聊授权人员。',robotPage],['dws','DWS 配置','查看当前本机 DWS 登录账号信息。',document.querySelector('.dws-settings')],['agent','Agent 引擎','选择默认执行引擎,并配置群内切换指令。',agentSettings],['prompt','提示词前缀','定义发送给 Agent 的群消息前缀,事件 JSON 将自动附加。',promptSettings],['commands','任务控制','配置暂停当前任务的消息指令。',commandSettings],['rules','群监控规则','管理绑定指令以及需要监听的群和人员。',rulesPanel],['replies','Agent回复','查看最近的群聊与单聊 Agent 处理记录。',repliesPanel]];root.replaceChildren();const shell=document.createElement('div');shell.className='app-shell';const sidebar=document.createElement('aside');sidebar.className='app-sidebar';sidebar.innerHTML='<div class="brand"><img class="brand-mark brand-icon" src="/assets/omi-icon.png" alt="omi" /><div><div class="brand-name">oh-my-im</div><div class="brand-sub">DingTalk Agent Console</div></div></div><div class="nav-label">Workspace</div><nav class="app-nav"></nav><div class="sidebar-foot">版本:<span class="version">__OMI_VERSION__</span> · <a href="https://github.com/duzhenxun/oh-my-im" target="_blank" rel="noreferrer">开源项目</a></div>';const main=document.createElement('div');main.className='app-main';const topbar=document.createElement('header');topbar.className='app-topbar';topbar.innerHTML='<button class="menu-button" type="button" aria-label="打开菜单">☰</button><div class="topbar-copy"><h1></h1><p></p></div>';const content=document.createElement('div');content.className='app-content';pageDefs.forEach(([id,title,description,panel],index)=>{const nav=document.createElement('button');nav.type='button';nav.className='nav-item'+(index===0?' active':'');nav.dataset.page=id;nav.innerHTML='<span class="nav-index">0'+(index+1)+'</span><span>'+title+'</span>';sidebar.querySelector('.app-nav').append(nav);const page=document.createElement('section');page.className='app-page page-'+id+(index===0?' active':'');page.dataset.page=id;page.append(panel);content.append(page)});const scrim=document.createElement('div');scrim.className='mobile-scrim';main.append(topbar,content);shell.append(sidebar,main);root.append(shell,scrim,notice,saveControl);const desktopMedia=window.matchMedia('(min-width:821px)');const syncViewport=()=>{if(desktopMedia.matches)document.body.classList.remove('nav-open')};desktopMedia.addEventListener?.('change',syncViewport);syncViewport();const showPage=id=>{document.querySelectorAll('.nav-item').forEach(item=>item.classList.toggle('active',item.dataset.page===id));document.querySelectorAll('.app-page').forEach(page=>page.classList.toggle('active',page.dataset.page===id));const def=pageDefs.find(item=>item[0]===id)||pageDefs[0];topbar.querySelector('h1').textContent=def[1];topbar.querySelector('p').textContent=def[2];saveControl.style.display=id==='replies'?'none':'';if(location.hash!=='#'+id)history.replaceState(null,'','#'+id);document.body.classList.remove('nav-open');window.scrollTo({top:0,behavior:'instant'})};sidebar.querySelectorAll('.nav-item').forEach(item=>item.onclick=()=>showPage(item.dataset.page));topbar.querySelector('.menu-button').onclick=()=>document.body.classList.toggle('nav-open');scrim.onclick=()=>document.body.classList.remove('nav-open');const initialPage=pageDefs.some(item=>item[0]===location.hash.slice(1))?location.hash.slice(1):'robot';window.addEventListener('hashchange',()=>{const id=location.hash.slice(1);if(pageDefs.some(item=>item[0]===id))showPage(id)});showPage(initialPage);replyList.style.maxHeight='min(680px,calc(100vh - 230px))';replyList.style.overflowY='auto';replyList.style.display='flex';replyList.style.flexDirection='column';replyList.style.gap='12px';let loaded=false;
249
249
  const esc=v=>{const d=document.createElement('div');d.textContent=v??'';return d.innerHTML};const fmt=t=>t?new Date(t).toLocaleString('zh-CN',{hour12:false}):'-';const memberRole=role=>/群主|owner/i.test(role||'')?'(群主)':/管理|admin/i.test(role||'')?'(管理)':'';
250
250
  function option(value,label){const o=document.createElement('option');o.value=value;o.textContent=label;return o}
251
251
  function renderBotUsers(){const selected=document.querySelector('#botUserSelected'),admins=document.querySelector('#superAdminSelected');selected.replaceChildren();admins.replaceChildren();selectedBotUsers.forEach(user=>{const chip=document.createElement('span');chip.className='bot-user-chip';chip.append(document.createTextNode(user.name));const remove=document.createElement('button');remove.type='button';remove.textContent='×';remove.title='移除';remove.onclick=()=>{selectedBotUsers=selectedBotUsers.filter(item=>item.id!==user.id);selectedSuperAdminIds=selectedSuperAdminIds.filter(id=>id!==user.id);renderBotUsers()};chip.append(remove);selected.append(chip);const adminLabel=document.createElement('label');adminLabel.className='bot-user-result';const box=document.createElement('input');box.type='checkbox';box.checked=selectedSuperAdminIds.includes(user.id);box.onchange=()=>{selectedSuperAdminIds=box.checked?[...new Set([...selectedSuperAdminIds,user.id])]:selectedSuperAdminIds.filter(id=>id!==user.id)};const name=document.createElement('span');name.textContent=user.name;adminLabel.append(box,name);admins.append(adminLabel)})}
@@ -87,9 +87,17 @@ export function startDwsHistoryPolling(options) {
87
87
  }
88
88
  };
89
89
  void poll().catch((err) => log.warn(`history poll failed: ${String(err)}`));
90
- const timer = setInterval(() => {
90
+ // Use a short scheduler tick instead of fixing the interval at startup, so
91
+ // changing 拉取间隔(秒) in the Web UI affects the current polling process.
92
+ let lastPollStartedAt = Date.now();
93
+ const scheduler = setInterval(() => {
94
+ const seconds = Number(options.getConfig().historyPollIntervalSeconds);
95
+ const intervalMs = Math.max(1, Number.isFinite(seconds) ? seconds : DEFAULT_POLL_INTERVAL_SECONDS) * 1_000;
96
+ if (Date.now() - lastPollStartedAt < intervalMs)
97
+ return;
98
+ lastPollStartedAt = Date.now();
91
99
  void poll().catch((err) => log.warn(`history poll failed: ${String(err)}`));
92
- }, Math.max(1, Number(options.getConfig().historyPollIntervalSeconds || DEFAULT_POLL_INTERVAL_SECONDS)) * 1_000);
93
- timer.unref();
94
- return () => { stopped = true; clearInterval(timer); };
100
+ }, 1_000);
101
+ scheduler.unref();
102
+ return () => { stopped = true; clearInterval(scheduler); };
95
103
  }
@@ -666,9 +666,10 @@ function isIgnoredRobotEvent(event, config) {
666
666
  return normalized.includes("robotcode") || normalized === "botid" || normalized === "openbotid";
667
667
  });
668
668
  };
669
+ const senderDisplay = senderDisplayName(event).trim().toLowerCase();
669
670
  const isRobot = robotMarker(sender) ||
670
- (configuredRobotId && senderValues.includes(configuredRobotId)) ||
671
- (robotName && senderValues.some((value) => value.includes(robotName)));
671
+ (configuredRobotId && senderId === configuredRobotId) ||
672
+ (robotName && (senderDisplay === robotName || senderValues.some((value) => value.includes(robotName))));
672
673
  if (isRobot)
673
674
  return true;
674
675
  // DWS history records are not consistent about exposing the sender name.
@@ -719,6 +720,7 @@ async function handleBatch(events, groupId, cardState, sessions, cardClient, get
719
720
  const sessionId = sessions.get(sessionKey);
720
721
  log.info(`processing batch size=${events.length} groupSession=${sessionId ? "resume" : "new"}`);
721
722
  let card;
723
+ let stopCardUpdates = () => undefined;
722
724
  let latestVisibleContent = `${label} 正在处理...`;
723
725
  let elapsedTimer;
724
726
  const title = `【${label}】`;
@@ -783,13 +785,23 @@ async function handleBatch(events, groupId, cardState, sessions, cardClient, get
783
785
  let pendingCardUpdate;
784
786
  let pendingCardTimer;
785
787
  let cardUpdateInFlight;
788
+ let cardUpdatesStopped = false;
789
+ stopCardUpdates = () => {
790
+ cardUpdatesStopped = true;
791
+ if (pendingCardTimer)
792
+ clearTimeout(pendingCardTimer);
793
+ pendingCardTimer = undefined;
794
+ pendingCardUpdate = undefined;
795
+ };
786
796
  // Pi emits very small deltas quickly. Coalesce them into one complete card
787
797
  // snapshot per second so DingTalk receives fresh content without a backlog.
788
- const intervalMs = Math.max(500, getDashboardConfig().cardUpdateIntervalMs || 3_000);
798
+ const getCardIntervalMs = () => Math.max(500, getDashboardConfig().cardUpdateIntervalMs || 3_000);
789
799
  const pumpCardUpdate = () => {
790
- if (cardUpdateInFlight || !pendingCardUpdate)
800
+ if (cardUpdatesStopped || cardUpdateInFlight || !pendingCardUpdate)
791
801
  return;
792
- const remaining = intervalMs - (Date.now() - lastCardUpdateAt);
802
+ // Read the setting for every update. A Web change therefore affects an
803
+ // already running task, not only the next task.
804
+ const remaining = getCardIntervalMs() - (Date.now() - lastCardUpdateAt);
793
805
  if (remaining > 0) {
794
806
  if (!pendingCardTimer) {
795
807
  pendingCardTimer = setTimeout(() => {
@@ -819,7 +831,7 @@ async function handleBatch(events, groupId, cardState, sessions, cardClient, get
819
831
  }
820
832
  if (cardUpdateInFlight)
821
833
  await cardUpdateInFlight;
822
- if (pendingCardUpdate) {
834
+ if (pendingCardUpdate && !cardUpdatesStopped) {
823
835
  const latest = pendingCardUpdate;
824
836
  pendingCardUpdate = undefined;
825
837
  await cardClient.update(activeCard, latest.title, formatReply(latest.content, getDashboardConfig().replyFormat))
@@ -827,6 +839,8 @@ async function handleBatch(events, groupId, cardState, sessions, cardClient, get
827
839
  }
828
840
  };
829
841
  const updateCard = (title, content) => {
842
+ if (cardUpdatesStopped)
843
+ return;
830
844
  pendingCardUpdate = { title, content };
831
845
  pumpCardUpdate();
832
846
  };
@@ -905,6 +919,10 @@ async function handleBatch(events, groupId, cardState, sessions, cardClient, get
905
919
  if (card) {
906
920
  const stopped = queue?.paused === true;
907
921
  const stoppedContent = latestVisibleContent;
922
+ // Cancel the interval timer and all delayed updates before the final
923
+ // paused/failed card update. This prevents a queued update from arriving
924
+ // after stop and changing the title back to "处理中".
925
+ stopCardUpdates();
908
926
  await cardClient.update(card, stopped ? finishedTitle("🔴", "处理暂停") : finishedTitle("❌", "处理失败"), stopped ? stoppedContent : `${label} 处理失败:${message.slice(0, 2_000)}`)
909
927
  .catch((updateErr) => log.warn(`card failure update skipped: ${String(updateErr)}`));
910
928
  cardState.cards[groupId] = { cardBizId: card.cardBizId, status: stopped ? "failed" : "failed" };
@@ -1023,6 +1041,14 @@ function startGroupListener(seen, queues, cardState, sessions, cardClient, getDa
1023
1041
  log.debug(`ignored robot message event=${event.event_id || "unknown"}`);
1024
1042
  return;
1025
1043
  }
1044
+ // A bot acknowledgement may be returned by history before the sender
1045
+ // metadata is complete. Never feed our own steer acknowledgement back into
1046
+ // the active Pi task, even if robot detection missed that event.
1047
+ const eventContent = (event.content || event.text || "").trim();
1048
+ if (queues.get(groupId)?.running && /^已将这条消息作为引导发送给当前 Pi 任务[。.!!]?$/u.test(eventContent)) {
1049
+ log.debug(`ignored self steer acknowledgement group=${groupId}`);
1050
+ return;
1051
+ }
1026
1052
  const key = eventKey(event, groupId);
1027
1053
  if (key && seen.has(key))
1028
1054
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-im",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "author": {
5
5
  "name": "杜振训",
6
6
  "email": "duzhenxun@126.com"