Skip to content

Commit 581072d

Browse files
authored
Fix/home tutorial reset backend state (Project-N-E-K-O#1291)
* fix: reset home tutorial prompt state * fix: make tutorial reset remote frontend friendly * fix: secure tutorial reset request * fix: retry tutorial reset after csrf refresh * fix: signal tutorial teardown without completion
1 parent f09721f commit 581072d

10 files changed

Lines changed: 546 additions & 35 deletions

main_routers/system_router.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@
158158
record_tutorial_prompt_decision,
159159
record_tutorial_started,
160160
record_tutorial_completed,
161+
reset_tutorial_prompt_state,
161162
)
162163
from utils.storage_location_bootstrap import build_storage_location_bootstrap_payload
163164
from utils.config_manager import get_config_manager as get_runtime_config_manager
@@ -1097,6 +1098,16 @@ async def post_tutorial_prompt_decision(request: Request):
10971098
return JSONResponse(status_code=400, content={"ok": False, "error": str(exc)})
10981099

10991100

1101+
@router.post("/tutorial-prompt/reset")
1102+
async def post_tutorial_prompt_reset(request: Request):
1103+
"""重置主页新手引导状态,供记忆浏览的手动重置入口调用。"""
1104+
validation_error = _validate_local_mutation_request(request)
1105+
if validation_error is not None:
1106+
return validation_error
1107+
1108+
return reset_tutorial_prompt_state(config_manager=get_config_manager())
1109+
1110+
11001111
@router.get("/autostart-prompt/state")
11011112
async def get_autostart_prompt_state():
11021113
"""返回开机自启动提示状态快照。"""

static/app-tutorial-prompt.js

Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -871,6 +871,34 @@
871871
return state.tutorialRunToken;
872872
}
873873

874+
async function persistHomeTutorialCompletion(event, flowStep, persistedStep) {
875+
const source = event.detail.source || 'manual';
876+
const tutorialRunToken = await waitForTutorialRunToken(2000);
877+
logFlow(flowStep, {
878+
source: source,
879+
promptToken: shortPromptToken(state.promptDrivenTutorialToken),
880+
tutorialRunToken: shortTutorialRunToken(tutorialRunToken),
881+
});
882+
883+
if (!tutorialRunToken) {
884+
logFlow(`${flowStep}-skipped`, {
885+
source: source,
886+
reason: 'missing_run_token',
887+
});
888+
state.promptDrivenTutorialToken = null;
889+
return;
890+
}
891+
892+
await persistTutorialLifecycle('/api/tutorial-prompt/tutorial-completed', {
893+
page: 'home',
894+
source: source,
895+
tutorial_run_token: tutorialRunToken,
896+
}, persistedStep, {
897+
clearRunTokenOnSuccess: true,
898+
});
899+
state.promptDrivenTutorialToken = null;
900+
}
901+
874902
function takeHeartbeatSnapshot() {
875903
const snapshot = {
876904
foregroundMsDelta: consumeForegroundDelta(),
@@ -1334,31 +1362,11 @@
13341362
endHomeTutorialFeatureSuppression('tutorial-completed');
13351363
emitHomeTutorialLockIfChanged('tutorial-completed');
13361364
void (async function () {
1337-
const source = event.detail.source || 'manual';
1338-
const tutorialRunToken = await waitForTutorialRunToken(2000);
1339-
logFlow('tutorial-completed', {
1340-
source: source,
1341-
promptToken: shortPromptToken(state.promptDrivenTutorialToken),
1342-
tutorialRunToken: shortTutorialRunToken(tutorialRunToken),
1343-
});
1344-
1345-
if (!tutorialRunToken) {
1346-
logFlow('tutorial-completed-skipped', {
1347-
source: source,
1348-
reason: 'missing_run_token',
1349-
});
1350-
state.promptDrivenTutorialToken = null;
1351-
return;
1352-
}
1353-
1354-
await persistTutorialLifecycle('/api/tutorial-prompt/tutorial-completed', {
1355-
page: 'home',
1356-
source: source,
1357-
tutorial_run_token: tutorialRunToken,
1358-
}, 'tutorial-completed-persisted', {
1359-
clearRunTokenOnSuccess: true,
1360-
});
1361-
state.promptDrivenTutorialToken = null;
1365+
await persistHomeTutorialCompletion(
1366+
event,
1367+
'tutorial-completed',
1368+
'tutorial-completed-persisted'
1369+
);
13621370
})();
13631371
scheduleFastHeartbeat();
13641372
});
@@ -1416,8 +1424,19 @@
14161424
}
14171425
state.tutorialRunning = false;
14181426
state.tutorialStartRequested = false;
1427+
state.tutorialStarted = true;
1428+
state.homeTutorialCompleted = true;
1429+
markHomeTutorialStorageSeen();
14191430
endHomeTutorialFeatureSuppression('tutorial-skipped');
14201431
emitHomeTutorialLockIfChanged('tutorial-skipped');
1432+
void (async function () {
1433+
await persistHomeTutorialCompletion(
1434+
event,
1435+
'tutorial-skipped',
1436+
'tutorial-skipped-persisted'
1437+
);
1438+
})();
1439+
scheduleFastHeartbeat();
14211440
});
14221441

14231442
// Keep this recovery bridge paired with handlePromptAcceptance:

static/js/character_personality_onboarding.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@
239239
const cleanup = () => {
240240
window.removeEventListener('neko:tutorial-completed', handleCompleted);
241241
window.removeEventListener('neko:tutorial-skipped', handleSkipped);
242+
window.removeEventListener('neko:tutorial-ended-without-completion', handleEndedWithoutCompletion);
242243
};
243244
const finish = () => {
244245
if (settled) {
@@ -250,8 +251,10 @@
250251
};
251252
const handleCompleted = () => finish();
252253
const handleSkipped = () => finish();
254+
const handleEndedWithoutCompletion = () => finish();
253255
window.addEventListener('neko:tutorial-completed', handleCompleted, { once: true });
254256
window.addEventListener('neko:tutorial-skipped', handleSkipped, { once: true });
257+
window.addEventListener('neko:tutorial-ended-without-completion', handleEndedWithoutCompletion, { once: true });
255258
});
256259
}
257260

static/universal-tutorial-manager.js

Lines changed: 112 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,61 @@ function logTutorialPromptFlow(step, details = {}) {
2929
console.log(TUTORIAL_PROMPT_FLOW_PREFIX + ' ' + step, details);
3030
}
3131

32+
async function getTutorialMutationHeaders() {
33+
const headers = { 'Content-Type': 'application/json' };
34+
const helper = window.nekoLocalMutationSecurity;
35+
if (helper && typeof helper.getMutationHeaders === 'function') {
36+
try {
37+
return Object.assign(headers, await helper.getMutationHeaders());
38+
} catch (error) {
39+
console.warn('[Tutorial] 获取本地写入安全头失败,尝试直接读取页面配置:', error);
40+
}
41+
}
42+
43+
try {
44+
const response = await fetch('/api/config/page_config', { cache: 'no-store' });
45+
if (!response.ok) {
46+
return headers;
47+
}
48+
const data = await response.json();
49+
if (data && typeof data.autostart_csrf_token === 'string' && data.autostart_csrf_token) {
50+
headers['X-CSRF-Token'] = data.autostart_csrf_token;
51+
}
52+
} catch (error) {
53+
console.warn('[Tutorial] 读取页面配置失败,继续使用基础请求头:', error);
54+
}
55+
return headers;
56+
}
57+
58+
async function postTutorialPromptReset(reason) {
59+
const body = JSON.stringify({ reason });
60+
const sendResetRequest = async () => fetch('/api/tutorial-prompt/reset', {
61+
method: 'POST',
62+
headers: await getTutorialMutationHeaders(),
63+
body,
64+
});
65+
66+
let response = await sendResetRequest();
67+
if (response.status === 403 && window.nekoLocalMutationSecurity &&
68+
typeof window.nekoLocalMutationSecurity.refreshToken === 'function') {
69+
let shouldRetry = false;
70+
try {
71+
const payload = await response.clone().json();
72+
shouldRetry = payload && payload.error_code === 'csrf_validation_failed';
73+
} catch (_) {
74+
shouldRetry = false;
75+
}
76+
if (shouldRetry) {
77+
await window.nekoLocalMutationSecurity.refreshToken();
78+
response = await sendResetRequest();
79+
}
80+
}
81+
if (!response.ok) {
82+
throw new Error(`tutorial prompt reset failed: ${response.status}`);
83+
}
84+
return response.json();
85+
}
86+
3287
window.getTutorialStorageKeyForPage = getTutorialStorageKeyForPage;
3388
window.getTutorialManualIntentKeyForPage = getTutorialManualIntentKeyForPage;
3489
window.logTutorialPromptFlow = logTutorialPromptFlow;
@@ -4735,6 +4790,24 @@ class UniversalTutorialManager {
47354790

47364791
this._teardownTutorialUI();
47374792

4793+
if (endMeta.reason === 'destroy') {
4794+
window.dispatchEvent(new CustomEvent('neko:tutorial-ended-without-completion', {
4795+
detail: {
4796+
page: this.currentPage,
4797+
source: completedSource,
4798+
reason: endMeta.rawReason
4799+
}
4800+
}));
4801+
this.logPromptFlow('tutorial-ended-without-completion', {
4802+
page: this.currentPage,
4803+
source: completedSource,
4804+
reason: endMeta.reason,
4805+
rawReason: endMeta.rawReason
4806+
});
4807+
console.log('[Tutorial] 引导未完成即结束,页面:', this.currentPage, 'reason:', endMeta.rawReason);
4808+
return;
4809+
}
4810+
47384811
// 标记用户已看过该页面的引导
47394812
const storageKey = this.getStorageKey();
47404813
localStorage.setItem(storageKey, 'true');
@@ -4744,6 +4817,24 @@ class UniversalTutorialManager {
47444817
console.log('[Tutorial] 已标记模型管理通用步骤为已看过');
47454818
}
47464819

4820+
if (endMeta.reason === 'skip') {
4821+
window.dispatchEvent(new CustomEvent('neko:tutorial-skipped', {
4822+
detail: {
4823+
page: this.currentPage,
4824+
source: completedSource,
4825+
reason: endMeta.rawReason
4826+
}
4827+
}));
4828+
this.logPromptFlow('tutorial-skipped', {
4829+
page: this.currentPage,
4830+
source: completedSource,
4831+
reason: endMeta.reason,
4832+
rawReason: endMeta.rawReason
4833+
});
4834+
console.log('[Tutorial] 引导已跳过并标记看过,页面:', this.currentPage);
4835+
return;
4836+
}
4837+
47474838
window.dispatchEvent(new CustomEvent('neko:tutorial-completed', {
47484839
detail: {
47494840
page: this.currentPage,
@@ -5145,7 +5236,12 @@ class UniversalTutorialManager {
51455236
/**
51465237
* 重置所有页面的引导状态
51475238
*/
5148-
resetAllTutorials() {
5239+
async resetHomeTutorialPromptState(reason = 'manual_home_tutorial_reset') {
5240+
return postTutorialPromptReset(reason);
5241+
}
5242+
5243+
async resetAllTutorials() {
5244+
await this.resetHomeTutorialPromptState('manual_all_tutorial_reset');
51495245
TUTORIAL_PAGES.forEach(page => {
51505246
this.getStorageKeysForPage(page).forEach(key => localStorage.removeItem(key));
51515247
});
@@ -5157,12 +5253,16 @@ class UniversalTutorialManager {
51575253
/**
51585254
* 重置指定页面的引导状态
51595255
*/
5160-
resetPageTutorial(pageKey) {
5256+
async resetPageTutorial(pageKey) {
51615257
if (pageKey === 'all') {
5162-
this.resetAllTutorials();
5258+
await this.resetAllTutorials();
51635259
return;
51645260
}
51655261

5262+
if (pageKey === 'home') {
5263+
await this.resetHomeTutorialPromptState('manual_home_tutorial_reset');
5264+
}
5265+
51665266
this.getStorageKeysForPage(pageKey).forEach((storageKey) => {
51675267
const oldVal = localStorage.getItem(storageKey);
51685268
localStorage.removeItem(storageKey);
@@ -5295,11 +5395,12 @@ async function initUniversalTutorialManager() {
52955395
* 全局函数:重置所有引导
52965396
* 供 HTML 按钮调用
52975397
*/
5298-
function resetAllTutorials() {
5398+
async function resetAllTutorials() {
52995399
if (window.universalTutorialManager) {
5300-
window.universalTutorialManager.resetAllTutorials();
5400+
await window.universalTutorialManager.resetAllTutorials();
53015401
} else {
53025402
// 如果管理器未初始化,直接清除 localStorage
5403+
await postTutorialPromptReset('manual_all_tutorial_reset');
53035404
TUTORIAL_PAGES.forEach(page => { localStorage.removeItem(getTutorialStorageKeyForPage(page)); });
53045405
localStorage.setItem(getTutorialManualIntentKeyForPage('home'), 'true');
53055406
}
@@ -5310,12 +5411,12 @@ function resetAllTutorials() {
53105411
* 全局函数:重置指定页面的引导
53115412
* 供下拉菜单调用
53125413
*/
5313-
function resetTutorialForPage(pageKey) {
5414+
async function resetTutorialForPage(pageKey) {
53145415
if (!pageKey) return;
53155416
console.log('%c[Tutorial] resetTutorialForPage 被调用, pageKey:', 'color: red; font-weight: bold', pageKey);
53165417

53175418
if (pageKey === 'all') {
5318-
resetAllTutorials();
5419+
await resetAllTutorials();
53195420
return;
53205421
}
53215422

@@ -5351,8 +5452,11 @@ function resetTutorialForPage(pageKey) {
53515452
}
53525453

53535454
if (window.universalTutorialManager) {
5354-
window.universalTutorialManager.resetPageTutorial(pageKey);
5455+
await window.universalTutorialManager.resetPageTutorial(pageKey);
53555456
} else {
5457+
if (pageKey === 'home') {
5458+
await postTutorialPromptReset('manual_home_tutorial_reset');
5459+
}
53565460
if (pageKey === 'model_manager') {
53575461
localStorage.removeItem(getTutorialStorageKeyForPage('model_manager'));
53585462
localStorage.removeItem(getTutorialStorageKeyForPage('model_manager_live2d'));

0 commit comments

Comments
 (0)