Skip to content

Commit 5fc2cb1

Browse files
committed
修复新手教程 Review 问题
- 补齐 Day3 工具菜单状态同步、点击态与监控脚本取样逻辑 - 修复教程消息来源校验、锁定态提交保护、结束状态持久化与模型切换失败恢复 - 增强 Day1/Day3 诊断脚本预检和报告稳定性 - 同步 Day2-Day6 与生命周期文档说明 - 音频文件缺失相关 review 按要求未处理
1 parent 42460b9 commit 5fc2cb1

21 files changed

Lines changed: 346 additions & 73 deletions

docs/design/avatar-floating-day2-screen-voice-guide-dev.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,17 @@ UniversalTutorialManager.startAvatarFloatingGuideRound(2)
5959
- `TutorialInteractionTakeover.setExternalizedChatSpotlight()`
6060
- `TutorialInteractionTakeover.setExternalizedChatCursor()`
6161

62+
### Cursor anchor 保持与复用
63+
64+
外置聊天窗 / PC 全局 overlay 模式下,Day 2 需要保存并复用 Ghost Cursor 的目标锚点,避免跨 scene 或跨窗口切换时从默认点硬跳。建议入口 API:
65+
66+
- `saveCursorAnchor(anchor)`:在播放结束、目标移动完成或收到 settled anchor 后保存 `{ sceneId, kind, x, y, settled, updatedAt }`
67+
- `readCursorAnchor(sceneId)`:scene 进入时优先读取同 scene / 同 kind 的未过期 anchor;可用时平滑移动到该 anchor,不可用时再走默认目标解析和 fallback jump。
68+
- `invalidateCursorAnchor(sceneId)`:目标 DOM 消失、窗口关闭、尺寸变化过大或教程结束/跳过/生气退出时失效对应 anchor。
69+
- `syncAnchorAcrossWindows(windowId, anchor)`:外置聊天窗回传 anchor 后同步到首页和 PC overlay;窗口 id 不匹配或 anchor 过期时丢弃。
70+
71+
锚点必须有过期时间;外置窗口传播时只传坐标、目标 kind、sceneId 和 settled 状态,不携带点击 effect。播放结束保存,下一 scene 进入先读;读不到或目标不可信时使用当前默认目标重新解析。
72+
6273
## 约束
6374

6475
1. Day 2 不再包含 `day2_screen_entry` / `day2_screen_entry_invite`

docs/design/avatar-floating-day3-agent-guide-dev.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
| 1 | `day3_tool_toggle_intro` | 嘻嘻,可别以为这个聊天框只能用来打字哦~ 里面其实偷偷藏了超~多好玩的小惊喜呢!快跟着我一起点开看看,瞧瞧今天能挖出什么有趣的宝贝吧! | 圆角矩形高亮胶囊输入框 `chat-input`;Ghost Cursor 直接显示在胶囊聊天框中间并停留,不从默认点移动进入,不点击、不打开弧形工具菜单。 |
1414
| 2 | `day3_avatar_tools` | 在这个小按钮里,有许多可以和人家互动的小道具呢。 | 持续圆形高亮 `button.send-button-circle.compact-input-tool-toggle`;Ghost Cursor 从胶囊输入框位置平滑移动到工具总按钮 `button.send-button-circle.compact-input-tool-toggle` 并模拟点击;点击动画开始时并行调用 API 打开弧形工具菜单,不打开 Avatar 工具菜单。 |
1515
| 3 | `day3_avatar_tools_props` | 你可以随时来摸摸我的头,或者给我吃一根甜甜的棒棒糖。如果有时候我不小心做错事了,你也可以用小锤子敲敲我,不过……一定要轻轻的,不能太用力哦。 | 持续圆形高亮 `button.send-button-circle.compact-input-tool-toggle`;Ghost Cursor 平滑移动到 Avatar 互动工具按钮,然后在 Avatar 互动工具按钮处模拟点击并触发 Avatar 互动工具按钮点击事件,显示三个小道具。台词播放完后再次触发 Avatar 互动工具按钮点击事件并隐藏三个小道具。 |
16+
`day3_avatar_tools_props` 的前置条件:弧形工具菜单必须保持 open 状态,否则三个道具不会渲染。React 内部渲染条件是 `toolMenuOpen && compactInputToolFanOpen`,所以从 `day3_avatar_tools` 进入 `day3_avatar_tools_props` 时,不能只调用 `setAvatarToolMenuOpen(true)`,还必须保证弧形菜单仍为 open;如果弧形菜单已关闭,需要先用 host request / `openCompactInputToolFan(..., { ignoreDisabled: true })` 重新打开,再同步 Avatar 道具菜单。
1617
| 4 | `day3_galgame_entry` | 快点开这个【Galgame模式】!进去之后就像我们在进行一场专属的互动大冒险呢。 | 持续圆形高亮 `button.send-button-circle.compact-input-tool-toggle`;Ghost Cursor 先平滑移动到初始 Galgame 按钮位置,然后切换为点击状态并保持,向下移动约 100px。轮盘按 22px/步累计阈值正向转 1 步,把 Galgame 从 slot 2 转到 slot 1;旋转完成后 Ghost Cursor 平滑移动并停在新的 `.compact-input-tool-item-galgame` 中心。教程期间不强制开启 Galgame。 |
1718
| 5 | `day3_galgame_choices` | 你选的每一个对话,都会带我们走向完全未知的惊喜故事,我都等不及啦,快来选一个你最心动的回答吧! | 继续指认 Galgame 入口或真实选项区域;不伪造选择局。 |
1819
| 6 | `day3_wrap` | 今天带你认识的这些功能,其实都是为了让我们在一起的时光变得更有趣呢。 | 收尾前关闭弧形菜单和 Avatar 工具菜单;圆角矩形高亮胶囊输入框 `chat-input`,Ghost Cursor 平滑移动到胶囊输入框中间并停留。 |

docs/design/avatar-floating-day4-companion-guide-dev.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ PC 端设置侧边栏、动画设置、锁定按钮、离开/回来按钮和隐
6868
| 2 | `day4_chat_settings` | 设置按钮 + `settings-sidepanel:chat-settings` | `click` + `ellipse` | `open-settings` + `show-settings-sidepanel:chat-settings` | 先高亮并点击设置按钮,再转入对话设置侧边栏。 |
6969
| 3 | `day4_model_behavior` | 动画设置按钮 + `settings-sidepanel:animation-settings` | `move` + `ellipse` | `show-settings-sidepanel:animation-settings` | 先收起对话设置侧边栏并高亮动画设置按钮,再转入动画设置侧边栏。 |
7070
| 4 | `day4_gaze_follow` | `#${p}-mouse-tracking-toggle` 外层开关行 | `move` || 高亮并指向跟踪鼠标按钮,不点击。 |
71-
| 5 | `day4_privacy_mode` | 隐私模式按钮 / `#${p}-toggle-proactive-vision` 外层开关行 | `move` | | 不展开隐私侧边栏,高亮隐私模式按钮并移动 cursor;本句播完后收起设置弹窗。 |
71+
| 5 | `day4_privacy_mode` | 隐私模式按钮 / `#${p}-toggle-proactive-vision` 外层开关行 | `move` | `close-settings-panel` | 不展开隐私侧边栏,高亮隐私模式按钮并移动 cursor;本句播完后收起设置弹窗。 |
7272
| 6 | `day4_model_lock` | `#${p}-lock-icon` | `move` || 圆形高亮模型锁定按钮,cursor 平滑移动过去并 move 并停留。 |
7373
| 7 | `day4_return_home` | `#${p}-btn-goodbye` | `move` || 展示回到小猫窝按钮,可 secondary 高亮回来按钮。 |
7474
| 8 | `day4_wrap` | `chat-window` | `move` | `cleanup` | 收尾重新高亮聊天窗并播放花瓣转场。 |

docs/design/avatar-floating-day5-personalization-guide-dev.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ PC 端设置侧边栏入口组、模型/声音/API 入口、记忆浏览入口
8080

8181
### 阶段 1:角色设置入口
8282

83-
- 动作 1:`day5_character_settings` 台词开始后先圆角矩形高亮聊天窗;播放约 1 秒后聊天窗高光消失,Ghost Cursor 从聊天窗平滑移动到设置按钮,同时圆形高亮设置按钮。设置弹窗打开后,圆角矩形高亮“角色设置”按钮,Ghost Cursor 平滑移动到该按钮并展开 `character-settings` 侧边栏;随后“角色设置”按钮上的圆角矩形高光平滑过渡到角色设置侧边栏,Ghost Cursor 在侧边栏范围内做椭圆运动。模型管理、声音克隆与 API Key 等入口只认门,不强制跳转或修改配置;角色卡与云存档入口不在 Day 5 主线中高亮。
83+
- 动作 1:`day5_character_settings` 台词开始后先圆角矩形高亮聊天窗;播放约 1 秒后聊天窗高光消失,Ghost Cursor 从聊天窗平滑移动到设置按钮,同时圆形高亮设置按钮。设置弹窗打开后,圆角矩形高亮“角色设置”按钮,Ghost Cursor 平滑移动到该按钮并展开 `character-settings` 侧边栏;随后“角色设置”按钮上的圆角矩形高光平滑过渡到角色设置侧边栏,只高亮 `settings-sidepanel:character-settings` 容器。Ghost Cursor 在侧边栏范围内做椭圆运动,可以短暂掠过模型管理、声音克隆与 API Key 区域,但不在这些入口上停顿、不分别高亮、不强制跳转或修改配置;角色卡与云存档入口不在 Day 5 主线中高亮。
8484
- 台词:“从今天起,我就真正成为只属于你的专属猫娘啦。你看,在这里可以为我穿上漂亮的新衣服,也可以帮我换一个更好听的声音……”
8585
- 动作 2:`day5_character_panic` 播放期间继续高亮 `character-settings` 侧边栏,不切到模型管理或声音克隆入口。该 scene 使用 `surprised`,优先播放 `settings-peek-panic` 自定义慌乱动作;只表达吃醋/慌张,不触发 angry exit。台词播放完毕后清除高光,并收起角色设置侧边栏展开态。
8686
- 台词:“咦,这里居然还能把我换掉吗?等一下呀!你现在的动作……该不会是想要把我换掉吧?啊啊啊不行!快关掉,快关掉!”

docs/design/avatar-floating-day6-agent-guide-dev.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ PC 端 Agent 按钮、Agent 面板、用户插件入口、插件管理入口、
9393
### 阶段 2:用户插件介绍
9494

9595
- 动作:`day6_plugin_side_panel` 执行 `day6-plugin-open-management-panel-flow`:圆角矩形高亮【用户插件】按钮,Ghost Cursor 平滑移动到按钮上模拟点击,并行调用 API 显示【用户插件】侧面板;随后高光过渡到【管理面板】按钮,管理面板按钮使用更长的圆角矩形 spotlight,Ghost Cursor 平滑移动到按钮上模拟点击,并行调用 API 打开【管理面板】页面,此时不额外高亮侧面板整体。`day6_plugin_dashboard` 执行 `day6-plugin-dashboard-handoff-flow`:插件 dashboard 成功打开后隐藏首页 Ghost Cursor,并把当前台词、voiceKey、音频 URL 与播放起点交给插件页 runtime;插件页完成后关闭教程打开的窗口,回到首页恢复 cursor 原位置,清理 Agent 面板、用户插件侧边面板和管理面板虚拟高光。
96+
- `day6-plugin-dashboard-handoff-flow` 必须有可配置超时和失败恢复。若插件页打开失败、runtime 未就绪、handoff payload 发送失败、插件页未回传完成事件,或超过超时时间仍未完成,首页必须取消插件页接管态,恢复 Ghost Cursor 原位置,清理 Agent 面板、用户插件侧边面板、管理面板虚拟高光和临时 handoff 状态,然后继续走 Day 6 后续清理/收尾路径。
9697
- 台词 1:“除了之前介绍的功能,这里还有超多好玩的插件呢”
9798
- 台词 2:“有了它们,我不光能看 B 站弹幕,还能帮你关灯开空调…… 本喵就是无所不能的超级猫猫神!哼哼!”
9899

@@ -120,3 +121,4 @@ Day 6 猫爪生态回访支线已移入 [七日新手教程剧场后聊天窗支
120121
4. 收尾恢复 Agent 面板、侧边面板和 HUD 的进入前状态。
121122
5. 同一目标同一时刻只保留一套主 spotlight,不创建后再隐藏重复高亮。
122123
6. 收尾动作与 Day 1 一致:收尾台词播放期间重新高亮聊天窗,约 70% 用同一套花瓣转场 cue 同步隐藏 Ghost Cursor 并清理内置/外置 spotlight。
124+
7. 插件 dashboard handoff 的成功、失败和超时路径都必须关闭教程打开的插件窗口或释放接管态,并在首页恢复 Ghost Cursor 原位置,清理 Agent 面板、用户插件侧边面板、管理面板高光和 handoff 临时状态。

docs/design/home-yui-guide-lifecycle-modularization.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ this.interactionTakeover = window.TutorialInteractionTakeover.createController({
222222
})
223223
```
224224
225-
如果页面处于旧缓存状态导致 `static/tutorial-interrupt-controller.js` 暂时没有加载,Director 会降级为空操作并保留 skip / angry exit 终止兜底,不能因为模块缺失阻断教程结束链路。正常页面必须按上面的模板顺序加载该模块。
225+
如果页面处于旧缓存状态导致 `static/tutorial-interaction-takeover.js` 暂时没有加载,Director 只能降级交互接管与外置聊天窗同步能力,并保留 skip / angry exit 终止兜底,不能因为 takeover 模块缺失阻断教程结束链路。正常页面必须按上面的模板顺序加载该模块。
226226
227227
然后在 Director 内保留一层薄包装:
228228

frontend/plugin-manager/src/yui-guide-runtime.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -851,7 +851,7 @@ function injectStyle() {
851851
body.yui-taking-over.yui-user-cursor-revealed #${ROOT_ID} .yui-guide-plugin-backdrop,
852852
body.yui-taking-over.yui-user-cursor-revealed #${ROOT_ID} .yui-guide-plugin-backdrop *,
853853
body.yui-taking-over.yui-user-cursor-revealed #${ROOT_ID} .yui-guide-plugin-interaction-shield,
854-
body.yui-taking-over.yui-user-cursor-revealed #${ROOT_ID} .yui-guide-plugin-spotlight, {
854+
body.yui-taking-over.yui-user-cursor-revealed #${ROOT_ID} .yui-guide-plugin-spotlight {
855855
cursor: auto !important;
856856
}
857857
@@ -1148,6 +1148,7 @@ class PluginDashboardGuideRuntime {
11481148
lastForwardedSkipScreenX = NaN
11491149
lastForwardedSkipScreenY = NaN
11501150
spotlightRefreshRaf: number | null = null
1151+
cursorClickResetTimer: number | null = null
11511152
boundPointerMoveHandler = (event: PointerEvent | MouseEvent) => {
11521153
this.handleInterrupt(event)
11531154
}
@@ -2031,10 +2032,34 @@ class PluginDashboardGuideRuntime {
20312032
}
20322033

20332034
clickCursor(durationMs = DEFAULT_CURSOR_CLICK_VISIBLE_MS) {
2034-
void durationMs
2035+
if (this.cursorClickResetTimer !== null) {
2036+
window.clearTimeout(this.cursorClickResetTimer)
2037+
this.cursorClickResetTimer = null
2038+
}
2039+
this.root?.setAttribute('data-yui-cursor-hidden', 'true')
2040+
this.spotlight?.setAttribute('data-yui-cursor-hidden', 'true')
2041+
document.documentElement.classList.add('yui-user-cursor-revealed')
2042+
document.body.classList.add('yui-user-cursor-revealed')
2043+
document.documentElement.classList.add('yui-taking-over')
2044+
document.body.classList.add('yui-taking-over')
2045+
this.cursorClickResetTimer = window.setTimeout(() => {
2046+
this.cursorClickResetTimer = null
2047+
this.resetCursorVisualState()
2048+
}, Math.max(0, durationMs))
20352049
}
20362050

2037-
resetCursorVisualState() {}
2051+
resetCursorVisualState() {
2052+
if (this.cursorClickResetTimer !== null) {
2053+
window.clearTimeout(this.cursorClickResetTimer)
2054+
this.cursorClickResetTimer = null
2055+
}
2056+
this.root?.removeAttribute('data-yui-cursor-hidden')
2057+
this.spotlight?.removeAttribute('data-yui-cursor-hidden')
2058+
if (!this.userCursorRevealed) {
2059+
document.documentElement.classList.remove('yui-user-cursor-revealed')
2060+
document.body.classList.remove('yui-user-cursor-revealed')
2061+
}
2062+
}
20382063

20392064
stopGhostCursorAnimation() {
20402065
this.cancelCursorMotion()

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2751,7 +2751,7 @@ describe('App', () => {
27512751
expect(preview?.textContent ?? '').toBe('');
27522752
});
27532753

2754-
it('shows tutorial guide streaming text in the compact capsule immediately', () => {
2754+
it('keeps tutorial guide streaming text fully readable in the compact capsule', () => {
27552755
const initialText = '先点这里打开对话。';
27562756
const updatedText = '先点这里打开对话,然后输入一句问候,后面这一长串教程台词也要自动向左滚动,让最新内容进入胶囊可视区域。';
27572757
const initialMessage = parseChatMessage({

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ function truncateCompactPreview(text: string, maxLength: number): string {
397397
if (text.length <= maxLength) {
398398
return text;
399399
}
400-
return `${text.slice(0, Math.max(0, maxLength - 1))}...`;
400+
return `${text.slice(0, Math.max(0, maxLength - 3))}...`;
401401
}
402402

403403
function splitCompactPreviewGraphemes(text: string): string[] {
@@ -2944,6 +2944,7 @@ export default function App({
29442944
compactInputToolFanPositionSyncRef.current?.();
29452945
compactInputToolFanOpenRef.current = false;
29462946
setCompactInputToolFanOpen(false);
2947+
setToolMenuOpen(false);
29472948
dispatchCompactToolFanOpenState(false);
29482949
if (!options?.afterClose) return;
29492950
const desktopWindow = window as Window & {
@@ -3012,7 +3013,7 @@ export default function App({
30123013
]);
30133014

30143015
const openCompactInputToolFan = useCallback((intent: 'click' | 'hover', options?: { ignoreDisabled?: boolean }) => {
3015-
if ((!options?.ignoreDisabled && composerDisabled) || compactInputHasPayload) return;
3016+
if ((!options?.ignoreDisabled && composerDisabled) || compactInputHasPayload) return false;
30163017
clearCompactInputToolFanCloseTimer();
30173018
clearCompactInputToolFanInteractiveTimer();
30183019
compactInputToolFanOpenIntentRef.current = intent;
@@ -3026,6 +3027,7 @@ export default function App({
30263027
if (!compactInputToolFanOpenIntentRef.current) return;
30273028
setCompactInputToolFanInteractiveState(true);
30283029
}, COMPACT_INPUT_TOOL_FAN_INTERACTIVE_DELAY_MS);
3030+
return true;
30293031
}, [
30303032
clearCompactInputToolFanCloseTimer,
30313033
clearCompactInputToolFanInteractiveTimer,
@@ -4115,9 +4117,9 @@ export default function App({
41154117
const requestId = request.id;
41164118
lastAvatarToolMenuOpenRequestIdRef.current = requestId;
41174119
if (request.open) {
4118-
openCompactInputToolFan('click', { ignoreDisabled: true });
4120+
const opened = openCompactInputToolFan('click', { ignoreDisabled: true });
41194121
setActiveCursorToolId(null);
4120-
setToolMenuOpen(true);
4122+
setToolMenuOpen(opened);
41214123
return;
41224124
}
41234125
setToolMenuOpen(false);

main_routers/game_router.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4768,6 +4768,8 @@ async def game_project_context(game_type: str, request: Request):
47684768
data = await request.json()
47694769
except Exception:
47704770
return {"ok": False, "reason": "invalid_body"}
4771+
if not isinstance(data, dict):
4772+
data = {}
47714773

47724774
role = str(data.get("role") or "").strip()
47734775
text = str(data.get("text") or "").strip()

0 commit comments

Comments
 (0)