Description
独立文档路由 /d/:docId 被设计为脱离主应用壳运行:Layout 通过 early return 直接渲染独立文档页,不挂载 MainPage / NavRail,页面内没有聊天列表、未读入口或消息来源展示。
但应用入口在 React 渲染和路由分支判断之前已经执行 WKApp.shared.startup()。登录态存在时,startup() 会继续调用 startMain() / connectIM();BaseModule 注册的全局 addCurrentImMessageListener 也仍然有效。只要收到一条通过 allowNotify() 的 IM 消息,该独立文档标签页就会调用 tipsAudio() 播放 msg-tip.mp3。
这造成了一个底层生命周期边界问题:页面在产品形态上是无聊天上下文的独立分享/阅读/权限申请页面,在运行时却默认继承完整聊天通知能力。用户可能只听到聊天提示音,却无法在当前页面识别发送人、频道或点击返回对应会话。实际曾在无权限文档页提交访问申请并将页面置于后台后,出现连续提示音,但 Octo 聊天列表中没有可确认的对应消息;该次具体事件来源目前无法稳定复现,因此不把免打扰群、好友申请、通知机器人或事件重放写成已确认根因。
已验证的执行路径:
apps/web/src/index.tsx 在渲染 <App /> 前注册 BaseModule 并调用 WKApp.shared.startup()。
- 登录态下
startup() 调用 startMain(),随后执行 connectIM()。
/d/:docId 在 apps/web/src/Layout/index.tsx 通过 early return 渲染独立文档页,明确跳过 MainPage / NavRail。
packages/dmworkbase/src/module.tsx 的全局消息监听器不区分当前路由是否具有聊天上下文;allowNotify() 返回 true 后直接调用 tipsAudio()。
allowNotify() 没有独立页面策略,也没有 document.hidden、跨标签页协调或按 messageID/clientMsgNo 的提示音去重。
免打扰缓存缺失、Space 初始化竞态、子区 fail-open、IM 重连或事件重放可能放大该问题,但尚未证明它们是原始现场的具体事件来源。本 Issue 跟踪的是已经由代码确认的底层边界:无 App Shell 的独立文档页默认继承全局聊天提示音。
受影响的是通过分享链接打开文档的组织成员、无权限申请者和外部协作者。声音没有可见来源,会干扰阅读/权限申请流程,也无法让用户判断是工作消息、文档事件还是异常重复通知。
Steps to Reproduce
- 在已登录状态下直接打开任意独立文档链接
/d/:docId,包括无权限的 403 / 申请访问页面。
- 保持该页面打开,或将标签页置于后台。
- 从另一账号向当前账号发送一条会通过全局
allowNotify() 的 IM 消息。
- 观察独立文档标签页仍可播放聊天提示音,但当前页面没有聊天列表、消息来源或跳转入口。
原始“连续提示音且没有可确认聊天消息”的现场目前无法稳定复现;以上步骤用于验证底层通知边界,而不是声称已经复现原始事件来源。
Expected Behavior
无 App Shell 的独立文档页面应有明确的通知隔离策略。至少在无权限/申请访问终端页,不应播放与当前页面无关的聊天提示音。
如果产品决定在有权限的独立阅读/编辑页保留全局消息通知,也应保证通知来源可见、可跳转,并可靠遵循免打扰、Space 和多标签页策略;不能只播放一个无法追踪来源的声音。
Actual Behavior
独立文档页虽跳过聊天 App Shell,但全局 IM 连接和提示音监听器仍然运行。符合 allowNotify() 的消息事件会触发 msg-tip.mp3,当前页面没有能力说明声音来自哪个会话。
已观察到一次无权限文档页在后台连续播放提示音的现场,但具体上游事件尚未捕获;本 Issue 不将该现场归因到某种特定消息类型。
Client Version
Web main@f5145fe12e3b5ab04acdcc7cfd8f707abf038b8e(2026-07-28 代码核查);原始生产观察发生于 2026-07-24,页面未展示具体部署构建号。
Platform
Web
Affected Component
Other
Severity
P3 - UX issue
Reproducibility
Environment
- Client: Octo Web
- Route:
/d/:docId
- Original browser / OS version: 未记录
- Original state: 无文档权限,已提交访问申请,标签页置于后台
Logs / Screenshots
当前没有原始现场日志或稳定复现录像。代码证据:
- 应用在渲染路由前注册模块并启动:
|
WKApp.shared.registerModule(new BaseModule()); // 基础模块 |
|
WKApp.shared.registerModule(new DataSourceModule()) // 数据源模块 |
|
WKApp.shared.registerModule(new LoginModule()); // 登录模块 |
|
WKApp.shared.registerModule(new BindModule()); // OIDC 自助绑定页 (/oidc/bind) |
|
WKApp.shared.registerModule(new ContactsModule()); // 联系模块 |
|
WKApp.shared.registerModule(new SummaryModule()); // 智能总结模块 |
|
WKApp.shared.registerModule(new McpMarketModule()); // MCP 市场模块 |
|
WKApp.shared.registerModule(new SkillMarketModule()); // Skill 市场模块 |
|
WKApp.shared.registerModule(new AppBotModule()); // App Bot 模块 |
|
WKApp.shared.registerModule(new DocsModule()); // Docs module |
|
WKApp.shared.registerModule(new LoopModule()); // Loop 面板(Issue/Skill/Project/Agent/Squad/Runtime) |
|
WKApp.shared.registerModule(new PersonalModule()); // 我的(Runtimes/Skills) |
|
async function main(): Promise<void> { |
|
await enableMocksIfE2E(); |
|
await enableMockImIfE2E(); |
|
WKApp.shared.startup(); // app启动 |
|
|
|
const container = document.getElementById("root")!; |
|
const root = createRoot(container); |
|
root.render( |
|
<React.StrictMode> |
- 登录态启动全局 IM:
|
registerImConnectAddressProvider( |
|
WKSDK.shared(), |
|
this.imConnectAddressManager.connectAddrCallback |
|
); |
|
|
|
WKApp.endpoints.addOnLogin(() => { |
|
this.startMain(); |
|
}); |
|
|
|
if (WKApp.loginInfo.isLogined()) { |
|
this.startMain(); |
|
} |
|
|
|
registerImConnectStatusListener(WKSDK.shared(), { |
|
logout: () => WKApp.shared.logout(), |
|
resetTyping: () => TypingManager.shared.resetAll(), |
|
rotateConnectAddress: () => |
|
this.imConnectAddressManager.rotateAfterDisconnect(), |
|
}); |
|
|
|
// 通知设置 |
|
const notificationIsClose = StorageService.shared.getItem( |
|
"NotificationIsClose" |
|
); |
|
if (notificationIsClose === "1") { |
|
this._notificationIsClose = true; |
|
} else { |
|
this._notificationIsClose = false; |
|
} |
|
|
|
WKApp.remoteConfig.startRequestConfig(); |
|
this.setupRemoteConfigForegroundRefresh(); |
|
startMain() { |
|
this.connectIM(); |
|
WKApp.dataSource.contactsSync(); // 同步通讯录 |
|
ProhibitwordsService.shared.sync(); // 同步敏感词 |
|
|
|
// 设备记录不存在(status===400)或其它读取失败时,仅记录告警以消除 |
|
// unhandled promise rejection;不写 clientMsgDeviceId,保持原值降级运行。 |
|
// 服务端暂无设备注册端点,此处不做注册,仅兜底。 |
|
syncClientMsgDeviceId({ |
|
deviceId: WKApp.shared.deviceId, |
|
fetchDevice: (path) => WKApp.apiClient.get(path), |
|
setClientMsgDeviceId: (id) => { |
|
WKSDK.shared().config.clientMsgDeviceId = id; |
|
}, |
|
}); |
|
} |
|
|
|
connectIM() { |
|
// e2e: mock-im-runtime 已 short-circuit connect → Connected (fake-provider install 时做), |
|
// 这里跳过真实 sdk.connect() 免真去建 WebSocket / 覆盖 mock 的 connect status. |
|
// dev / prod 完全走 tree-shake 分支, 无副作用. |
|
if (import.meta.env.VITE_E2E_MOCK_IM === "1") { |
|
return; |
|
} |
|
connectImClient({ |
|
sdk: WKSDK.shared(), |
|
loginInfo: WKApp.loginInfo, |
|
}); |
|
} |
- 独立文档页跳过
MainPage / NavRail:
|
// Standalone document deep-link (`/d/:docId`, octo-web #512): a shareable full-window |
|
// doc view that lives OUTSIDE the app shell (no MainPage / NavRail), mounted with the |
|
// same early-return interception the `?invite=` landing uses below. We claim the whole |
|
// `/d` namespace (not just well-formed ids) so a malformed/empty id renders the |
|
// standalone not-found terminal instead of silently falling through to the shell (AC-9). |
|
// |
|
// Auth: this branch renders before the Provider, so ensure the octo session is loaded |
|
// for the page's GET /docs/{docId} preflight + collab-token exchange. A clean cold-load |
|
// in a fresh tab carries no `?sid=`, so load() (sid-keyed) misses even a signed-in user's |
|
// token — recover it from localStorage the way the invite branch does (AC-3). When the |
|
// visitor is genuinely anonymous, fall through to the login screen rendered IN PLACE: the |
|
// pathname stays `/d/:docId`, so onLogin derives basePath from it and bounces the user |
|
// straight back to the doc (now with `?sid=`) after sign-in — the deep-link resumes |
|
// instead of dead-ending (AC-11). SPA deep-link serving depends on the nginx try_files |
|
// fallback (deployment concern, out of scope for this frontend change). |
|
if (isStandaloneDocPath(window.location.pathname)) { |
|
if (!WKApp.loginInfo.token) { |
|
WKApp.loginInfo.load(); |
|
} |
|
if (!WKApp.loginInfo.token) { |
|
recoverOctoSessionFromStorage(true); |
|
} |
|
if (WKApp.loginInfo.token) { |
|
const standaloneDocId = parseStandaloneDocId(window.location.pathname); |
|
return <StandaloneDocPage docId={standaloneDocId} onSessionExpired={clearExpiredStandaloneSessionAndReload} />; |
|
} |
- 全局消息监听器触发通知和提示音:
|
if (this.allowNotify(message)) { |
|
let from = ""; |
|
if (message.channel.channelType === ChannelTypeGroup) { |
|
const fromChannelInfo = getCurrentImChannelInfo( |
|
new Channel(message.fromUID, ChannelTypePerson) |
|
); |
|
if (fromChannelInfo) { |
|
from = `${fromChannelInfo?.orgData.displayName}: `; |
|
} |
|
} |
|
this.sendNotification( |
|
message, |
|
`${from}${message.content.conversationDigest}` |
|
); |
|
this.tipsAudio(); |
|
} |
tipsAudio() 与当前通知过滤条件:
|
tipsAudio() { |
|
Howler.autoUnlock = false; |
|
if (!this.messageTone) { |
|
this.messageTone = new Howl({ |
|
src: require("./assets/msg-tip.mp3"), |
|
}); |
|
this.messageTone.play(); |
|
} else { |
|
this.messageTone.play(); |
|
} |
|
} |
|
|
|
allowNotify(message: Message) { |
|
if (WKApp.shared.notificationIsClose) { |
|
// 用户关闭了通知 |
|
return false; |
|
} |
|
if (isCurrentImSystemMessage(message.contentType)) { |
|
// 系统消息不发通知 |
|
return false; |
|
} |
|
if (message.fromUID === WKApp.loginInfo.uid) { |
|
// 自己发的消息不发通知 |
|
return false; |
|
} |
|
// Space 隔离:不属于当前 Space 的消息不弹通知、不播提示音 |
|
if (shouldSkipMessageForSpace(message)) { |
|
return false; |
|
} |
|
// BotFather 消息额外检查:channelType=Person 绕过了上面的过滤, |
|
// 需通过消息体 contentObj.space_id 判断是否属于当前 Space |
|
if (message.channel?.channelID === "botfather") { |
|
const curSpaceId = WKApp.shared.currentSpaceId; |
|
const msgSpaceId = (message.content as any)?.contentObj?.space_id; |
|
if (curSpaceId && msgSpaceId && msgSpaceId !== curSpaceId) { |
|
return false; |
|
} |
|
} |
|
|
|
// 已屏蔽(免打扰)的 channel 不播提示音、不发通知 |
|
const channelInfo = getCurrentImChannelInfo(message.channel); |
|
if (channelInfo?.mute) { |
|
return false; |
|
} |
|
// 子区消息:额外检查父群聊 mute |
|
const parentGroupNo = channelInfo?.orgData?.parentGroupNo as |
|
| string |
|
| undefined; |
|
if (parentGroupNo) { |
|
const parentChannelInfo = getCurrentImChannelInfo( |
|
new Channel(parentGroupNo, ChannelTypeGroup) |
|
); |
|
if (parentChannelInfo?.mute) { |
|
return false; |
|
} |
|
} |
|
|
|
return true; |
- WuKongIM JS SDK v1.3.5 收到消息后直接通知监听器,不会先等待频道资料加载:
https://github.com/WuKongIM/WuKongIMJSSDK/blob/v1.3.5/src/chat_manager.ts#L51-L74
历史关联:
Description
独立文档路由
/d/:docId被设计为脱离主应用壳运行:Layout通过 early return 直接渲染独立文档页,不挂载MainPage/NavRail,页面内没有聊天列表、未读入口或消息来源展示。但应用入口在 React 渲染和路由分支判断之前已经执行
WKApp.shared.startup()。登录态存在时,startup()会继续调用startMain()/connectIM();BaseModule注册的全局addCurrentImMessageListener也仍然有效。只要收到一条通过allowNotify()的 IM 消息,该独立文档标签页就会调用tipsAudio()播放msg-tip.mp3。这造成了一个底层生命周期边界问题:页面在产品形态上是无聊天上下文的独立分享/阅读/权限申请页面,在运行时却默认继承完整聊天通知能力。用户可能只听到聊天提示音,却无法在当前页面识别发送人、频道或点击返回对应会话。实际曾在无权限文档页提交访问申请并将页面置于后台后,出现连续提示音,但 Octo 聊天列表中没有可确认的对应消息;该次具体事件来源目前无法稳定复现,因此不把免打扰群、好友申请、通知机器人或事件重放写成已确认根因。
已验证的执行路径:
apps/web/src/index.tsx在渲染<App />前注册BaseModule并调用WKApp.shared.startup()。startup()调用startMain(),随后执行connectIM()。/d/:docId在apps/web/src/Layout/index.tsx通过 early return 渲染独立文档页,明确跳过MainPage/NavRail。packages/dmworkbase/src/module.tsx的全局消息监听器不区分当前路由是否具有聊天上下文;allowNotify()返回 true 后直接调用tipsAudio()。allowNotify()没有独立页面策略,也没有document.hidden、跨标签页协调或按messageID/clientMsgNo的提示音去重。免打扰缓存缺失、Space 初始化竞态、子区 fail-open、IM 重连或事件重放可能放大该问题,但尚未证明它们是原始现场的具体事件来源。本 Issue 跟踪的是已经由代码确认的底层边界:无 App Shell 的独立文档页默认继承全局聊天提示音。
受影响的是通过分享链接打开文档的组织成员、无权限申请者和外部协作者。声音没有可见来源,会干扰阅读/权限申请流程,也无法让用户判断是工作消息、文档事件还是异常重复通知。
Steps to Reproduce
/d/:docId,包括无权限的 403 / 申请访问页面。allowNotify()的 IM 消息。原始“连续提示音且没有可确认聊天消息”的现场目前无法稳定复现;以上步骤用于验证底层通知边界,而不是声称已经复现原始事件来源。
Expected Behavior
无 App Shell 的独立文档页面应有明确的通知隔离策略。至少在无权限/申请访问终端页,不应播放与当前页面无关的聊天提示音。
如果产品决定在有权限的独立阅读/编辑页保留全局消息通知,也应保证通知来源可见、可跳转,并可靠遵循免打扰、Space 和多标签页策略;不能只播放一个无法追踪来源的声音。
Actual Behavior
独立文档页虽跳过聊天 App Shell,但全局 IM 连接和提示音监听器仍然运行。符合
allowNotify()的消息事件会触发msg-tip.mp3,当前页面没有能力说明声音来自哪个会话。已观察到一次无权限文档页在后台连续播放提示音的现场,但具体上游事件尚未捕获;本 Issue 不将该现场归因到某种特定消息类型。
Client Version
Web
main@f5145fe12e3b5ab04acdcc7cfd8f707abf038b8e(2026-07-28 代码核查);原始生产观察发生于 2026-07-24,页面未展示具体部署构建号。Platform
Web
Affected Component
Other
Severity
P3 - UX issue
Reproducibility
Environment
/d/:docIdLogs / Screenshots
当前没有原始现场日志或稳定复现录像。代码证据:
octo-web/apps/web/src/index.tsx
Lines 70 to 81 in f5145fe
octo-web/apps/web/src/index.tsx
Lines 127 to 135 in f5145fe
octo-web/packages/dmworkbase/src/App.tsx
Lines 829 to 860 in f5145fe
octo-web/packages/dmworkbase/src/App.tsx
Lines 974 to 1002 in f5145fe
MainPage/NavRail:octo-web/apps/web/src/Layout/index.tsx
Lines 410 to 435 in f5145fe
octo-web/packages/dmworkbase/src/module.tsx
Lines 595 to 610 in f5145fe
tipsAudio()与当前通知过滤条件:octo-web/packages/dmworkbase/src/module.tsx
Lines 690 to 747 in f5145fe
https://github.com/WuKongIM/WuKongIMJSSDK/blob/v1.3.5/src/chat_manager.ts#L51-L74
历史关联: