Skip to content

Commit 0dc68a5

Browse files
authored
Fix home tutorial interaction isolation and recovery (#1160)
* Fix stalled text sessions after tutorial timeout Release the start-session guard when an inactive session is ended by an external timeout, while preserving the internal cleanup path that keeps the guard closed during start_session handoff. Clear stale pending input before releasing the guard so a previous inactive cleanup cannot erase input cached by a new session attempt. Add unit coverage for frontend timeout recovery, internal cleanup guard preservation, and the pending-input race. * fix(tutorial): isolate home guide interactions Add a unified home tutorial interaction lock and wire it through homepage send, image import, screenshot, paste, embedded React composer, and greeting checks. Add backend greeting_check TTL guard and tests so tutorial blocking does not affect normal chat/session actions. * fix(tutorial): close manually opened plugin guide window Treat the plugin dashboard opened by the highlighted manual fallback target as owned by the current tutorial step, while preserving user-owned pre-existing dashboard windows. * docs(tutorial): update home guide interaction notes Refresh the Yui guide reference with the current interaction lock, composer disable, greeting guard, cleanup, and verification expectations. * fix(tutorial): address home guide lock review Close composer menus and attachment removal while disabled, keep tutorial lock restoration in sync with underlying button state, clean up guard test state, and clarify guide docs. * fix(tutorial): close guide lock race gaps Re-check the home tutorial lock when image files are selected and before multi-window screenshot proxy requests. Trim the composer disable effect dependencies to the values it actually uses. * fix(tutorial): add locked toast translations Add tutorial.homeInteractionLocked to all locale files so the home tutorial lock toast resolves without falling back.
1 parent 9e6a543 commit 0dc68a5

21 files changed

Lines changed: 549 additions & 31 deletions

docs/design/home-tutorial-yui-guide-performance-owner-stage-breakdown.md

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
本文档按当前代码实现重写,仅作为日后维护首页新手引导时的参考。它不再是“演出层负责人阶段拆解”计划,也不再描述已经废弃的 driver.js popover 流程。
44

5-
当前基准日期`2026-04-30`
5+
预计生效日期`2026-05-06`
66

77
## 1. 当前范围
88

@@ -12,6 +12,7 @@
1212
- 在教程期间临时切换到 `yui-origin` Live2D 模型,不写入用户配置。
1313
- 由 Yui 旁白、聊天消息、表情轨道、Ghost Cursor、高亮和真实 UI 操作共同完成引导。
1414
- 演示猫爪、插件面板、设置面板后归还控制权。
15+
- 教程期间统一锁住非引导要求的首页交互和主动输出入口,完成、跳过、失败或销毁后恢复正常功能。
1516
- 用户强行抢夺鼠标时触发轻度抵抗,第三次有效打断进入生气退出。
1617
- 正常完成、跳过、生气退出、页面销毁都必须走统一清理。
1718

@@ -26,6 +27,9 @@
2627
- `static/yui-guide-wakeup.js`
2728
- `static/yui-guide-director.js`
2829
- `static/universal-tutorial-manager.js`
30+
- `static/app-buttons.js`
31+
- `static/app-react-chat-window.js`
32+
- `static/app-websocket.js`
2933
- `static/css/tutorial-styles.css`
3034
- `static/css/yui-guide.css`
3135

@@ -37,7 +41,12 @@
3741
- `static/yui-guide-overlay.js`:首页演出 DOM 层,负责 spotlight、圆形高亮、Ghost Cursor、预览层等。
3842
- `static/yui-guide-page-handoff.js`:跨页 handoff token、目标页打开包装、插件 Dashboard 打开辅助能力。
3943
- `frontend/plugin-manager/src/yui-guide-runtime.ts`:插件 Dashboard 页面内的本地演出、spotlight、Ghost Cursor、本地打断检测和与首页通信。
40-
- `static/app-tutorial-prompt.js`:首次引导提示、用户决策、教程开始/完成状态上报。
44+
- `static/app-tutorial-prompt.js`:首次引导提示、用户决策、教程开始/完成状态上报、统一首页教程交互锁。
45+
- `static/app-buttons.js`:首页旧发送入口、最终文本发送、截图、图片导入、剪贴板图片粘贴等入口的教程锁业务拦截。
46+
- `static/app-react-chat-window.js`:首页嵌入 React chat 宿主层,负责同步教程锁到 composer,并在宿主提交入口兜底拒绝。
47+
- `frontend/react-neko-chat/src/App.tsx``frontend/react-neko-chat/src/message-schema.ts`:React chat 组件层的 `composerDisabled`、输入/按钮/选项禁用状态和消息 schema。
48+
- `static/app-websocket.js`:前端 greeting check 阻塞、pending 重试、首页教程阻塞状态上报。
49+
- `main_routers/websocket_router.py`:后端 `home_tutorial_state` 接收和 `greeting_check` 教程态兜底。
4150
- `main_routers/system_router.py``utils/tutorial_prompt_state.py`:提示状态、教程生命周期、handoff token 后端状态。
4251

4352
聊天 UI 只以 React 实现为准,入口是 `frontend/react-neko-chat/` 构建出的组件。旧的 `#chat-container` 不应作为当前实现依据。
@@ -57,6 +66,19 @@
5766

5867
状态实现位于 `utils/tutorial_prompt_state.py`。对外快照会隐藏内部 token;开始和完成事件会通过 `neko:tutorial-started``neko:tutorial-completed` 与前端教程管理器联动。
5968

69+
### 3.1 首页教程交互锁
70+
71+
首页新手引导不只依赖演出层遮罩来挡点击。当前实现以 `static/app-tutorial-prompt.js` 暴露的统一锁作为业务判断入口:
72+
73+
```js
74+
window.isNekoHomeTutorialInteractionLocked()
75+
window.isNekoHomeTutorialBlockingGreeting()
76+
```
77+
78+
锁只在首页生效,覆盖提示判定/展示、提示弹窗打开、用户接受后等待 Yui flow 真正进入 running、教程 running、`window.isInTutorial`、以及 completed/skipped 前的运行窗口。状态变化会派发 `neko:home-tutorial-lock-changed`,供聊天、WebSocket greeting 和宿主 UI 同步。
79+
80+
该锁的目标是让“教程期间只允许教程要求的交互”成为业务规则,而不是只靠捕获事件层、CSS 层或某个单独入口的临时判断。锁释放后必须恢复正常聊天、截图、导入、greeting check 和已有主动搭话链路,不额外改变这些链路的原始触发条件。
81+
6082
## 4. 场景注册表
6183

6284
首页主流程顺序由 `HOME_SCENE_ORDER` 定义:
@@ -110,6 +132,18 @@ window.createYuiGuideDirector = function createYuiGuideDirector(options) {}
110132
`intro_basic` 会向聊天窗口追加教程消息,播放本地预录语音,并驱动表情轨道。外置 `/chat` 窗口模式下,教程消息和按钮锁定状态通过 `appInterpage` / BroadcastChannel 同步。
111133
首页普通模式的 prelude 激活提示是刻意例外:输入框上方的 overlay 气泡(例如“点一下这里,我就能开始说话啦~”)不进入聊天记录;正式教程旁白(例如 `intro_basic` 及后续旁白)才追加到对话窗。首页内嵌聊天直接 append 到 React chat;N.E.K.O.-PC 的外置 `/chat` 窗口通过 BroadcastChannel 注入教程消息,只有外置聊天窗通信失败时才退回 overlay 气泡兜底。
112134

135+
教程锁开启时,首页嵌入 React chat 必须进入硬禁用状态:输入框 disabled/readOnly,发送、截图、图片导入、工具按钮、GalGame 选项和 ChoicePrompt 选项不可触发。宿主层 `handleComposerSubmit()` 也要检查同一锁,防止键盘提交、焦点残留或程序调用绕过组件状态。
136+
137+
首页旧发送链路和最终发送函数也要检查同一锁。`sendTextPayload()``sendTextPayloadInternal()`、截图捕获、图片导入和剪贴板图片粘贴在锁开启时直接返回,不进入 `start_session``stream_data`、用户消息追加、TTS、状态机或主动搭话重置链路。这样教程期间不会污染正常对话链,教程结束后也不会留下半截 session。
138+
139+
独立 `/chat` 页已有 BroadcastChannel 禁用/恢复链,当前改造不替换这条链路。首页嵌入 React chat 的硬禁用只是补齐同等级状态锁,避免出现外置窗口可锁、首页嵌入窗口只靠遮罩挡点击的不一致。
140+
141+
### 6.1.1 greeting 与主动输出隔离
142+
143+
角色首次打招呼、久别打招呼和 greeting check 需要被教程锁延后,但不能被永久吞掉。前端 `static/app-websocket.js` 在锁开启时保留 `_greetingCheckPending`,不发送 `greeting_check`;锁解除后重新尝试 `_sendGreetingCheckIfReady()`,让原本符合条件的 greeting 继续按既有规则判断。
144+
145+
前端还会把首页教程阻塞状态通过 `home_tutorial_state` 发送给后端。后端只用这个状态兜底 `greeting_check`,并带 60 秒 TTL,避免前端断线或异常退出后让后端长期误以为仍在教程中。这个兜底不拦截普通 `stream_data``start_session`、语音会话、主动搭话开关或其他 WebSocket action。
146+
113147
### 6.2 猫爪接管
114148

115149
`takeover_capture_cursor` 会高亮猫爪按钮,Ghost Cursor 移动并模拟点击,随后真实打开猫爪面板,并启用相关 Agent 开关,例如总开关和键鼠控制能力。
@@ -244,6 +278,9 @@ token 由后端签名、带 TTL、同源校验、单次消费。目标页消费
244278
- 移除 `yui-taking-over``yui-guide-plugin-dashboard-running` 等接管 class。
245279
- 停止或销毁语音队列、表情轨道、wakeup、监听器、计时器。
246280
- 解锁 React 聊天按钮和输入区。
281+
- 释放首页教程交互锁,并派发锁状态变化事件。
282+
- 恢复首页旧发送入口、React composer、截图、图片导入、剪贴板图片粘贴入口的正常可用状态。
283+
- 通知 WebSocket greeting 链路恢复 pending 检查;后端教程阻塞状态依赖 TTL 自动兜底,不能长期污染 greeting。
247284
- 恢复首页真实 UI,不把用户原本的猫娘或主界面一起隐藏。
248285
- 如插件 Dashboard 窗口由教程创建,则在需要时关闭。
249286
- 恢复教程前的 Agent 开关快照。
@@ -259,6 +296,10 @@ token 由后端签名、带 TTL、同源校验、单次消费。目标页消费
259296
- 插件 Dashboard 的教程演出不要只看 `/ui/` 字面路径,要核对 `YuiGuidePageHandoff``yui-guide-runtime.ts` 的专用握手。
260297
- 任何新增教程文案或 i18n key,按项目规则同步所有 locale。
261298
- 任何退出路径都要验证清理,不能只在正常完成路径恢复状态。
299+
- 不要绕过 `isNekoHomeTutorialInteractionLocked()` 增加新的首页发送、截图、导入或粘贴入口。
300+
- 不要把教程态判断散落成多套互不一致的条件;greeting、首页发送和 React composer 应消费同一把锁。
301+
- 后端教程态兜底只应限制 greeting check,不能扩大到普通聊天、语音会话、主动搭话开关或其他正常 WebSocket 链路。
302+
- 锁释放必须以教程完成、跳过、失败恢复和页面销毁等完整退出路径为准,避免慢网络或脚本异常下提前释放或永久残留。
262303

263304
## 13. 回归检查清单
264305

@@ -272,13 +313,25 @@ token 由后端签名、带 TTL、同源校验、单次消费。目标页消费
272313
- `/ui/` 页面打断能回传首页,并由首页播放抵抗或 angry exit。
273314
- 跳过、完成、angry exit 后,首页可继续正常使用。
274315
- React 聊天输入、语音按钮、设置面板、猫爪面板不会残留教程锁定状态。
316+
- 教程期间首页 React chat 输入、发送、截图、图片导入、工具按钮、GalGame 选项和 ChoicePrompt 选项均不可触发。
317+
- 教程期间通过键盘提交、焦点残留或程序调用触发首页发送时,业务链路会拒绝,不产生 `start_session``stream_data`
318+
- 接受教程弹窗后到 Yui flow running 前,greeting check 不会穿透。
319+
- 教程完成、跳过或失败恢复后,首页普通对话、截图、图片导入和 greeting check 按原逻辑恢复。
320+
- 弱网、慢设备、头像覆盖超时或脚本初始化延迟下,教程锁不会提前释放或永久残留。
275321

276322
## 14. 快速验证命令
277323

278-
文档修改本身不需要运行前端构建。若同时改了教程脚本,至少执行
324+
文档修改本身不需要运行前端构建。若同时改了教程脚本,至少执行以下 Windows/PowerShell 示例;Linux/macOS 可将 `npm.cmd` 替换为 `npm`,并使用 `/` 路径分隔符
279325

280326
```powershell
327+
node --check static\app-tutorial-prompt.js
328+
node --check static\app-buttons.js
329+
node --check static\app-react-chat-window.js
330+
node --check static\app-websocket.js
281331
node --check static\universal-tutorial-manager.js
282332
node --check static\yui-guide-director.js
283-
git diff --check -- static\universal-tutorial-manager.js static\yui-guide-director.js docs\design\home-tutorial-yui-guide-performance-owner-stage-breakdown.md
333+
npm.cmd test
334+
uv run python -m py_compile main_routers\websocket_router.py
335+
uv run pytest tests\unit\test_websocket_home_tutorial_guard.py tests\unit\test_session_start_guard.py -q
336+
git diff --check -- docs\design\home-tutorial-yui-guide-performance-owner-stage-breakdown.md
284337
```

frontend/react-neko-chat/src/App.test.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,19 @@ describe('App', () => {
6969
expect(onComposerSubmit).toHaveBeenCalledWith({ text: 'Test send' });
7070
});
7171

72+
it('disables composer submission while the home tutorial owns interaction', () => {
73+
const onComposerSubmit = vi.fn();
74+
render(<App composerDisabled onComposerSubmit={onComposerSubmit} />);
75+
76+
const input = screen.getByPlaceholderText('Type a message...');
77+
expect(input).toBeDisabled();
78+
fireEvent.change(input, { target: { value: 'Blocked send' } });
79+
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
80+
81+
expect(onComposerSubmit).not.toHaveBeenCalled();
82+
expect(screen.getByRole('button', { name: 'Send' })).toBeDisabled();
83+
});
84+
7285
it('does not render a local optimistic user bubble before the host echoes messages', () => {
7386
const onComposerSubmit = vi.fn();
7487
render(<App onComposerSubmit={onComposerSubmit} />);
@@ -118,6 +131,26 @@ describe('App', () => {
118131
expect(onComposerRemoveAttachment).toHaveBeenCalledWith('img-1');
119132
});
120133

134+
it('keeps pending composer attachments locked while the composer is disabled', () => {
135+
const onComposerRemoveAttachment = vi.fn();
136+
137+
render(
138+
<App
139+
composerDisabled
140+
composerAttachments={[
141+
{ id: 'img-1', url: 'data:image/png;base64,aaa', alt: 'Screenshot 1' },
142+
]}
143+
onComposerRemoveAttachment={onComposerRemoveAttachment}
144+
/>,
145+
);
146+
147+
const removeButton = screen.getByRole('button', { name: 'Remove image: Screenshot 1' });
148+
expect(removeButton).toBeDisabled();
149+
fireEvent.click(removeButton);
150+
151+
expect(onComposerRemoveAttachment).not.toHaveBeenCalled();
152+
});
153+
121154
it('only emits avatar interactions when the pointer hits the avatar range', () => {
122155
const onAvatarInteraction = vi.fn();
123156
const live2dContainer = document.createElement('div');

frontend/react-neko-chat/src/App.tsx

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,7 @@ export default function App({
554554
messageListAriaLabel = i18n('chat.messageListAriaLabel', 'Chat messages'),
555555
composerToolsAriaLabel = i18n('chat.composerToolsAriaLabel', 'Composer tools'),
556556
composerHidden = false,
557+
composerDisabled = false,
557558
composerAttachments = [],
558559
composerAttachmentsAriaLabel = i18n('chat.pendingImagesAriaLabel', 'Pending attachments'),
559560
importImageButtonLabel = i18n('chat.importImage', 'Import Image'),
@@ -645,7 +646,7 @@ export default function App({
645646
const submittingRef = useRef(false);
646647
const lastRollbackKeyRef = useRef('');
647648
const lastToolCursorResetKeyRef = useRef('');
648-
const canSubmit = draft.trim().length > 0 || composerAttachments.length > 0;
649+
const canSubmit = !composerDisabled && (draft.trim().length > 0 || composerAttachments.length > 0);
649650
const clearActiveCursorToolSelection = useCallback(() => {
650651
clearGlobalToolCursorState();
651652
latestPointerTargetRef.current = null;
@@ -1394,10 +1395,10 @@ export default function App({
13941395
}, [hammerCursorOverlayActive, hammerSwingPhase]);
13951396

13961397
useEffect(() => {
1397-
if (composerHidden && activeCursorToolId) {
1398+
if (composerHidden || composerDisabled) {
13981399
clearActiveCursorToolSelection();
13991400
}
1400-
}, [activeCursorToolId, composerHidden]);
1401+
}, [clearActiveCursorToolSelection, composerHidden, composerDisabled]);
14011402

14021403
useEffect(() => {
14031404
function handleDeactivate() {
@@ -1412,6 +1413,7 @@ export default function App({
14121413
}, []);
14131414

14141415
function submitDraft() {
1416+
if (composerDisabled) return;
14151417
if (submittingRef.current) return;
14161418
const text = draft.trim();
14171419
if (!text && composerAttachments.length === 0) return;
@@ -1433,6 +1435,7 @@ export default function App({
14331435
aria-label={resolvedTranslateAriaLabel}
14341436
aria-pressed={translateEnabled}
14351437
title={translateButtonLabel}
1438+
disabled={composerDisabled}
14361439
onClick={() => onTranslateToggle?.()}
14371440
>
14381441
<img src="/static/icons/translate_icon.png" alt="" aria-hidden="true" />
@@ -1445,6 +1448,7 @@ export default function App({
14451448
type="button"
14461449
aria-label={jukeboxButtonAriaLabel}
14471450
title={jukeboxButtonLabel}
1451+
disabled={composerDisabled}
14481452
onClick={() => onJukeboxClick?.()}
14491453
>
14501454
<img src="/static/icons/jukebox_icon.png" alt="" aria-hidden="true" />
@@ -1458,6 +1462,7 @@ export default function App({
14581462
aria-label={resolvedGalgameAriaLabel}
14591463
aria-pressed={galgameModeEnabled}
14601464
title={galgameToggleButtonLabel}
1465+
disabled={composerDisabled}
14611466
onClick={() => onGalgameModeToggle?.()}
14621467
>
14631468
<span className="composer-galgame-btn-glyph" aria-hidden="true">G</span>
@@ -1473,6 +1478,7 @@ export default function App({
14731478
title={selectedEmojiButtonAriaLabel}
14741479
aria-controls={toolMenuOpen ? 'composer-tool-popover' : undefined}
14751480
aria-expanded={toolMenuOpen}
1481+
disabled={composerDisabled}
14761482
onClick={() => {
14771483
if (activeToolItem) {
14781484
clearActiveCursorToolSelection();
@@ -1496,6 +1502,7 @@ export default function App({
14961502
type="button"
14971503
aria-label={clearCursorToolAriaLabel}
14981504
title={clearCursorToolAriaLabel}
1505+
disabled={composerDisabled}
14991506
onClick={(event) => {
15001507
event.stopPropagation();
15011508
setIsCursorInsideHostWindow(true);
@@ -1527,6 +1534,7 @@ export default function App({
15271534
aria-pressed={activeCursorToolId === item.id}
15281535
aria-label={itemLabel}
15291536
title={itemLabel}
1537+
disabled={composerDisabled}
15301538
onClick={(event) => {
15311539
latestPointerPositionRef.current = {
15321540
x: event.clientX,
@@ -1705,7 +1713,13 @@ export default function App({
17051713
className="composer-attachment-remove"
17061714
type="button"
17071715
aria-label={`${removeAttachmentButtonAriaLabel}: ${attachment.alt || attachment.id}`}
1708-
onClick={() => onComposerRemoveAttachment?.(attachment.id)}
1716+
aria-disabled={composerDisabled}
1717+
disabled={composerDisabled}
1718+
onClick={() => {
1719+
if (!composerDisabled) {
1720+
onComposerRemoveAttachment?.(attachment.id);
1721+
}
1722+
}}
17091723
>
17101724
×
17111725
</button>
@@ -1724,6 +1738,8 @@ export default function App({
17241738
aria-label={inputPlaceholder}
17251739
rows={1}
17261740
value={draft}
1741+
readOnly={composerDisabled}
1742+
disabled={composerDisabled}
17271743
onChange={(event) => { setDraft(event.target.value); }}
17281744
onKeyDown={(event) => {
17291745
if (event.nativeEvent.isComposing) return;
@@ -1755,7 +1771,7 @@ export default function App({
17551771
type="button"
17561772
className="composer-galgame-option"
17571773
title={option.text}
1758-
disabled={galgameOptionsLoading}
1774+
disabled={composerDisabled || galgameOptionsLoading}
17591775
tabIndex={galgameOptionsVisible ? 0 : -1}
17601776
onClick={() => {
17611777
if (submittingRef.current) return;
@@ -1813,6 +1829,7 @@ export default function App({
18131829
type="button"
18141830
className="composer-galgame-option composer-choice-option"
18151831
title={option.label}
1832+
disabled={composerDisabled}
18161833
onClick={() => {
18171834
if (submittingRef.current) return;
18181835
submittingRef.current = true;
@@ -1838,6 +1855,7 @@ export default function App({
18381855
type="button"
18391856
aria-label={resolvedImportImageAriaLabel}
18401857
title={importImageButtonLabel}
1858+
disabled={composerDisabled}
18411859
onClick={() => onComposerImportImage?.()}
18421860
>
18431861
<img src="/static/icons/import_image_icon.png" alt="" aria-hidden="true" />
@@ -1848,6 +1866,7 @@ export default function App({
18481866
type="button"
18491867
aria-label={resolvedScreenshotAriaLabel}
18501868
title={screenshotButtonLabel}
1869+
disabled={composerDisabled}
18511870
onClick={() => onComposerScreenshot?.()}
18521871
>
18531872
<img src="/static/icons/screenshot_new_icon.png" alt="" aria-hidden="true" />
@@ -1887,6 +1906,7 @@ export default function App({
18871906
title={overflowMenuAriaLabel}
18881907
aria-haspopup="true"
18891908
aria-expanded={overflowMenuOpen}
1909+
disabled={composerDisabled}
18901910
onClick={() => setOverflowMenuOpen(open => !open)}
18911911
>
18921912
<svg

frontend/react-neko-chat/src/message-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ export const chatWindowPropsSchema = z.object({
197197
avatarGeneratorButtonLabel: z.string().optional(),
198198
avatarGeneratorButtonAriaLabel: z.string().optional(),
199199
composerHidden: z.boolean().optional(),
200+
composerDisabled: z.boolean().optional(),
200201
translateEnabled: z.boolean().optional(),
201202
translateButtonLabel: z.string().optional(),
202203
translateButtonAriaLabel: z.string().optional(),

0 commit comments

Comments
 (0)