From f9e105acd092b987ac9a3d34190dacbec3b7fa40 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 15 Sep 2026 17:17:18 +0800 Subject: [PATCH 1/3] feat(learn): add standalone /learn pages backed by Page Data Serve lightweight child/parent learning assistant pages at /learn without MindSpace injection, with a rebuild script and Portal static route. Co-authored-by: Cursor --- public/learn/index.html | 114 ++++++++++++++++++++++ public/learn/learning-assistant-data.js | 64 +++++++++++++ public/learn/parent.html | 116 +++++++++++++++++++++++ scripts/build-learn-standalone.py | 89 +++++++++++++++++ scripts/patch-learn-route-143.py | 42 ++++++++ server/portal-static-delivery-routes.mjs | 7 ++ 6 files changed, 432 insertions(+) create mode 100644 public/learn/index.html create mode 100644 public/learn/learning-assistant-data.js create mode 100644 public/learn/parent.html create mode 100644 scripts/build-learn-standalone.py create mode 100644 scripts/patch-learn-route-143.py diff --git a/public/learn/index.html b/public/learn/index.html new file mode 100644 index 0000000..d32a35f --- /dev/null +++ b/public/learn/index.html @@ -0,0 +1,114 @@ + + + + + +我的学习小助手 + + + + + + + + +
加载中…
+
+ + + + diff --git a/public/learn/learning-assistant-data.js b/public/learn/learning-assistant-data.js new file mode 100644 index 0000000..1955249 --- /dev/null +++ b/public/learn/learning-assistant-data.js @@ -0,0 +1,64 @@ +(function(global){ +"use strict"; +var client=null,cache={children:[],categories:[],templates:[],tasks:[],completions:[],settings:[]}; +var refreshAt=0,taskIndex=null,TOKEN_KEY="learn_pd_token",DATA_VER="20260915j"; +function cycleBounds(childId){var tpls=cache.templates.filter(function(t){return String(t.child_id)===String(childId)&&String(t.active)!=="0"});if(!tpls.length)return {cycleStart:"",cycleEnd:""};var starts=[],ends=[];tpls.forEach(function(t){var s=String(t.start_date||"").slice(0,10);if(s)starts.push(s);if(t.end_date){var e=String(t.end_date).slice(0,10);if(e)ends.push(e)}});starts.sort();ends.sort();return {cycleStart:starts[0]||"",cycleEnd:ends.length?ends[ends.length-1]:""}} +function tasksForDateInCycle(childId,ds){var b=cycleBounds(childId);if(!b.cycleStart||dsb.cycleEnd)return [];var ids={};cache.templates.forEach(function(t){if(String(t.child_id)===String(childId)&&String(t.active)!=="0")ids[String(t.id)]=true});return tasksForDate(childId,ds).filter(function(t){return t.template_id&&ids[String(t.template_id)]})} +function pageId(){return global.__MINDSPACE_PAGE_DATA__&&global.__MINDSPACE_PAGE_DATA__.pageId||null} +function parseWeekdays(raw){var s=String(raw||"1,2,3,4,5").trim();if(!s)return ["1","2","3","4","5"];if(s.charAt(0)==="{")return s.replace(/[{}\"]/g,"").split(",").filter(Boolean);return s.split(",").filter(Boolean)} +async function rows(name,q,retry){try{var r=await client.listRows(name,q||{limit:500});return r.rows||[]}catch(e){if((retry||0)<1&&(e.status>=500||e.code==="page_data_failed"||!e.code))return rows(name,q,(retry||0)+1);throw e}} +function liveRows(list){return (list||[]).filter(function(r){return r&&r.deleted_at==null&&String(r.active)!=="0"})} +function todayStr(){return new Date(Date.now()+28800000).toISOString().slice(0,10)} +function pad(n){return n<10?"0"+n:""+n} +function dowUTC(ds){return new Date(Date.UTC(+ds.slice(0,4),+ds.slice(5,7)-1,+ds.slice(8,10))).getUTCDay()} +function wdCN(d){return ["日","一","二","三","四","五","六"][d]} +function addDays(ds,n){var d=new Date(ds+"T00:00:00Z");d.setUTCDate(d.getUTCDate()+n);return d.toISOString().slice(0,10)} +function pageTokenKey(){return TOKEN_KEY+":"+(global.__MINDSPACE_PAGE_DATA__&&global.__MINDSPACE_PAGE_DATA__.pageId||"default")} +function saveToken(t){try{if(t)sessionStorage.setItem(pageTokenKey(),t);else sessionStorage.removeItem(pageTokenKey())}catch(e){}} +function loadToken(){try{return sessionStorage.getItem(pageTokenKey())||""}catch(e){return ""}} +function rebuildTaskIndex(){taskIndex={};cache.tasks.forEach(function(t){var k=String(t.child_id)+"|"+String(t.task_date).slice(0,10);if(!taskIndex[k])taskIndex[k]=[];taskIndex[k].push(t)})} +function invalidateTaskIndex(){taskIndex=null} +async function refresh(force){if(!force&&client&&refreshAt&&Date.now()-refreshAt<15000)return;var packs=await Promise.all([rows("learn_children"),rows("learn_categories"),rows("learn_templates"),rows("learn_tasks"),rows("learn_completions")]);cache.children=liveRows(packs[0]);cache.categories=liveRows(packs[1]);cache.templates=liveRows(packs[2]);cache.tasks=liveRows(packs[3]);cache.completions=liveRows(packs[4]);cache.settings=[];invalidateTaskIndex();refreshAt=Date.now();rows("learn_settings").then(function(r){cache.settings=liveRows(r)}).catch(function(){})} +function catById(id){return cache.categories.find(function(c){return String(c.id)===String(id)})||null} +function childById(id){return cache.children.find(function(c){return String(c.id)===String(id)})||null} +function tplActive(t,ds){if(String(t.active)==="0")return false;var s=String(t.start_date||"").slice(0,10);if(s&&dse)return false;var wd=dowUTC(ds);var wds=parseWeekdays(t.weekdays);return wds.indexOf(String(wd===0?7:wd))>=0||wds.indexOf(String(wd))>=0} +function compMap(childId){var m={};cache.completions.forEach(function(c){if(String(c.child_id)===String(childId))m[String(c.task_id)]=c});return m} +function mergeTask(t,c){var x=Object.assign({},t);if(c){x.status="done";x.value_submitted=c.value||"";x.remark=c.remark||"";x.completed_time=c.completed_time||""}return x} +function tasksForDate(childId,ds){if(!taskIndex)rebuildTaskIndex();var cm=compMap(childId);return (taskIndex[String(childId)+"|"+ds]||[]).map(function(t){return mergeTask(t,cm[String(t.id)])})} +async function ensureTasks(childId,ds){var existing=tasksForDate(childId,ds);var hasTpl={};existing.forEach(function(t){if(t.template_id)hasTpl[String(t.template_id)]=true});var pending=[];for(var i=0;i0)return "yellow";return ds=b.cycleStart;ds=addDays(ds,-1)){var tasks=tasksForDateInCycle(childId,ds);if(!tasks.length)continue;var done=tasks.filter(function(t){return t.status==="done"}).length;out.push({date:ds,done:done,total:tasks.length,status:dayStatus(tasks,ds),tasks:tasks})}return {cycleStart:b.cycleStart,cycleEnd:b.cycleEnd,data:out}} +async function getCalendar(childId,month){var m=month||todayStr().slice(0,7);var y=+m.slice(0,4),mo=+m.slice(5,7);var daysIn=new Date(Date.UTC(y,mo,0)).getUTCDate();var days=[];for(var d=1;d<=daysIn;d++){var ds=y+"-"+pad(mo)+"-"+pad(d);var tasks=tasksForDate(childId,ds);var done=tasks.filter(function(t){return t.status==="done"}).length;days.push({date:ds,done:done,total:tasks.length,status:dayStatus(tasks,ds)})}return {month:m,days:days}} +async function getGrowth(childId){var today=todayStr();var byCat={};var totalDone=0,best=0,fullDays=0,run=0;for(var i=89;i>=0;i--){var ds=addDays(today,-i);var tasks=tasksForDate(childId,ds);if(!tasks.length){run=0;continue}var st=dayStatus(tasks,ds);if(st==="green"){run++;fullDays++;if(run>best)best=run}else run=0;tasks.forEach(function(t){var k=t.category_name||"其他";if(!byCat[k])byCat[k]={name:k,done:0,total:0};byCat[k].total++;if(t.status==="done"){byCat[k].done++;totalDone++}})}return {energy:energyFor(childId),streak:streakFor(childId),best:best,fullDays:fullDays,totalDone:totalDone,byCategory:Object.keys(byCat).map(function(k){return byCat[k]})}} +async function checkin(id,value,remark){var task=cache.tasks.find(function(t){return Number(t.id)===Number(id)});if(!task||task.status==="done")throw new Error("任务不存在或已完成");var ds=String(task.task_date).slice(0,10);var now=new Date(Date.now()+28800000);var ct=pad(now.getUTCHours())+":"+pad(now.getUTCMinutes());var pts=Number(task.points||10);await client.insertRow("learn_completions",{task_id:Number(id),child_id:Number(task.child_id),task_date:ds,value:value==null?"":String(value),remark:remark||"",completed_time:ct,points:pts});cache.completions.push({task_id:id,child_id:task.child_id,task_date:ds,value:value,remark:remark,completed_time:ct,points:pts});var todayTasks=tasksForDate(task.child_id,ds);var done=todayTasks.filter(function(t){return t.status==="done"}).length+1;var total=todayTasks.length;return {done:done,total:total,allDone:done>=total,points:pts}} +async function parentStats(){await refresh();var today=todayStr();var weekStart=addDays(today,-6);var children=cache.children.map(function(c){return {name:c.name,emoji:c.emoji,streak:streakFor(c.id)}});var allToday={done:0,total:0},allWeek={done:0,total:0};var trend=[];for(var i=6;i>=0;i--){var ds=addDays(today,-i);var dDone=0,dTotal=0;for(var j=0;j=weekStart){allWeek.done+=dDone;allWeek.total+=dTotal}if(ds===today){allToday.done=dDone;allToday.total=dTotal}trend.push({d:ds,done:dDone,total:dTotal})}var missedMap={};for(var k=0;k<30;k++){var ds2=addDays(today,-k);for(var ci=0;ci + + + + +家长后台 + + + + + + + + +
加载中…
+ + + + diff --git a/scripts/build-learn-standalone.py b/scripts/build-learn-standalone.py new file mode 100644 index 0000000..441f33f --- /dev/null +++ b/scripts/build-learn-standalone.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Build lightweight /learn/* pages without MindSpace injection.""" +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / 'MindSpace/a70ff537-8908-486e-9b6c-042e07cc25db/public' +OUT = ROOT / 'public/learn' +VER = '20260915j' + +CHILD_PAGE = 'dcb1f3f0-db96-4dab-a009-7a9c393ebe77' +PARENT_PAGE = 'f9c80392-9417-46fd-af89-a91a239d3bfc' + + +def extract_style(html: str) -> str: + m = re.search(r'', html) + return m.group(1) if m else '' + + +def extract_app_script(html: str) -> str: + m = re.search(r'', html) + if not m: + raise ValueError('app script not found') + return m.group(1) + + +def shell(title: str, style: str, page_id: str, access: str, body: str, script: str) -> str: + access_json = '{"pageId":"%s","accessMode":"%s"}' % (page_id, access) + return f''' + + + + +{title} + + + + + + + +{body} + + + +''' + + +def main(): + OUT.mkdir(parents=True, exist_ok=True) + child_src = (SRC / 'learning-assistant.html').read_text(encoding='utf-8') + parent_src = (SRC / 'learning-assistant-parent.html').read_text(encoding='utf-8') + child_style = extract_style(child_src) + parent_style = extract_style(parent_src) + child_script = extract_app_script(child_src) + parent_script = extract_app_script(parent_src) + + child_script = child_script.replace('learning-assistant-parent.html', '/learn/parent.html') + parent_script = parent_script.replace('learning-assistant.html', '/learn/') + + child_body = ''' +
加载中…
+
+''' + + parent_body = ''' +
加载中…
+''' + + (OUT / 'index.html').write_text( + shell('我的学习小助手', child_style, CHILD_PAGE, 'public', child_body, child_script), + encoding='utf-8', + ) + (OUT / 'parent.html').write_text( + shell('家长后台', parent_style, PARENT_PAGE, 'password', parent_body, parent_script), + encoding='utf-8', + ) + data_js = (SRC / 'learning-assistant-data.js').read_text(encoding='utf-8') + data_js = re.sub(r'DATA_VER="[^"]+"', f'DATA_VER="{VER}"', data_js) + (OUT / 'learning-assistant-data.js').write_text(data_js, encoding='utf-8') + print('built', OUT) + print(' index.html', (OUT / 'index.html').stat().st_size, 'bytes') + print(' parent.html', (OUT / 'parent.html').stat().st_size, 'bytes') + + +if __name__ == '__main__': + main() diff --git a/scripts/patch-learn-route-143.py b/scripts/patch-learn-route-143.py new file mode 100644 index 0000000..43e84a2 --- /dev/null +++ b/scripts/patch-learn-route-143.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +from pathlib import Path + +p = Path("/Users/john/Project/Memind/server.mjs") +text = p.read_text() +if '"/learn"' in text and "public/learn" in text: + print("ALREADY_PATCHED") + raise SystemExit(0) + +needle = """ app2.use( + "/assets", + staticMiddleware( + pathApi.join(h5Root, "public/assets"), + { maxAge: "1d" } + ) + ); + app2.get( + "/dev/wechat-share-preview",""" + +insert = """ app2.use( + "/assets", + staticMiddleware( + pathApi.join(h5Root, "public/assets"), + { maxAge: "1d" } + ) + ); + app2.use( + "/learn", + staticMiddleware( + pathApi.join(h5Root, "public/learn"), + { maxAge: "5m", index: "index.html" } + ) + ); + app2.get( + "/dev/wechat-share-preview",""" + +if needle not in text: + print("NEEDLE_NOT_FOUND") + raise SystemExit(1) + +p.write_text(text.replace(needle, insert, 1)) +print("PATCHED_OK") diff --git a/server/portal-static-delivery-routes.mjs b/server/portal-static-delivery-routes.mjs index 4cb11b6..2824eb4 100644 --- a/server/portal-static-delivery-routes.mjs +++ b/server/portal-static-delivery-routes.mjs @@ -161,6 +161,13 @@ export function attachPortalStaticDeliveryRoutes({ { maxAge: '1d' }, ), ); + app.use( + '/learn', + staticMiddleware( + pathApi.join(h5Root, 'public/learn'), + { maxAge: '5m', index: 'index.html' }, + ), + ); app.get( '/dev/wechat-share-preview', From 264c213fbe6f78d2cba5cb52c0479c0b70dc700a Mon Sep 17 00:00:00 2001 From: john Date: Wed, 16 Sep 2026 08:53:58 +0800 Subject: [PATCH 2/3] fix(mindspace): use lightweight page sync for background list refresh Background page list sync was calling syncAndDeliver over the remote adapter, which could exceed the 60s timeout for heavy users and block Portal. Route list sync through pageSyncService only via listSync while keeping full delivery on Finish and agent-run paths. Co-authored-by: Cursor --- scripts/verify-mindspace-page-sync-guards.mjs | 13 ++++++++++++- server.mjs | 8 +++++--- server/portal-mindspace-page-core-routes.mjs | 2 +- server/portal-mindspace-page-core-routes.test.mjs | 13 ++++++++----- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/scripts/verify-mindspace-page-sync-guards.mjs b/scripts/verify-mindspace-page-sync-guards.mjs index 36f65f1..d1129ea 100644 --- a/scripts/verify-mindspace-page-sync-guards.mjs +++ b/scripts/verify-mindspace-page-sync-guards.mjs @@ -20,7 +20,18 @@ const contractSource = read('mindspace-server-adapter-contract.mjs'); assertIncludes( 'server.mjs syncUserGeneratedPages', serverSource, - 'await mindSpacePageSync.syncUserGeneratedPages(userId);', + 'mindSpacePageSync.syncUserGeneratedPages(userId', +); +assertIncludes( + 'server.mjs list page sync bypasses full delivery', + serverSource, + 'listSync = false', +); +const pageCoreRoutesSource = read('server/portal-mindspace-page-core-routes.mjs'); +assertIncludes( + 'portal page list background sync', + pageCoreRoutesSource, + 'syncUserGeneratedPages(userId, { listSync: true })', ); if (serverSource.includes("mindSpaceServerRuntime.adapterKind === 'remote'")) { throw new Error( diff --git a/server.mjs b/server.mjs index f836c9d..c95793b 100644 --- a/server.mjs +++ b/server.mjs @@ -1292,7 +1292,7 @@ async function listSessionPublicHtmlRelativePaths(userId, sessionId, { sinceMs = async function syncUserGeneratedPages( userId, - { sessionId = null, sinceMs = null, onlyRelativePaths = null } = {}, + { sessionId = null, sinceMs = null, onlyRelativePaths = null, listSync = false } = {}, ) { if (!userId) return; // Agent-run/Finish delivery must stay scoped to the current conversation. @@ -1330,11 +1330,13 @@ async function syncUserGeneratedPages( : sessionId ? (discoveredRelativePaths?.length ? discoveredRelativePaths : recentWorkspaceRelativePaths) : null; - if (workspacePageDeliver?.syncAndDeliver) { + if (!listSync && workspacePageDeliver?.syncAndDeliver) { return await workspacePageDeliver.syncAndDeliver(userId, { pageDataRelativePaths }); } if (!mindSpacePageSync) return; - return await mindSpacePageSync.syncUserGeneratedPages(userId); + return await mindSpacePageSync.syncUserGeneratedPages(userId, { + onlyRelativePaths: pageDataRelativePaths, + }); } async function resolveChatSaveBundle(user, h5Root, input = {}) { diff --git a/server/portal-mindspace-page-core-routes.mjs b/server/portal-mindspace-page-core-routes.mjs index 3f5fde4..f6adba6 100644 --- a/server/portal-mindspace-page-core-routes.mjs +++ b/server/portal-mindspace-page-core-routes.mjs @@ -46,7 +46,7 @@ export function createMindSpacePageListSyncScheduler({ lastStartedAtByUser.set(userId, currentTime); const task = Promise.resolve() - .then(() => syncUserGeneratedPages(userId)) + .then(() => syncUserGeneratedPages(userId, { listSync: true })) .catch((error) => { logger?.warn?.( `[MindSpace] background page list sync failed for user ${userId}:`, diff --git a/server/portal-mindspace-page-core-routes.test.mjs b/server/portal-mindspace-page-core-routes.test.mjs index 48c1f0a..6548dc3 100644 --- a/server/portal-mindspace-page-core-routes.test.mjs +++ b/server/portal-mindspace-page-core-routes.test.mjs @@ -230,8 +230,8 @@ test('page list background sync coalesces in-flight and throttled requests', asy const scheduler = createMindSpacePageListSyncScheduler({ now: () => currentTime, minIntervalMs: 30_000, - syncUserGeneratedPages(userId) { - calls.push(userId); + syncUserGeneratedPages(userId, options) { + calls.push({ userId, options }); return new Promise((resolve) => { resolvers.push(resolve); }); @@ -242,19 +242,22 @@ test('page list background sync coalesces in-flight and throttled requests', asy const second = scheduler('user-1'); assert.equal(first, second); await Promise.resolve(); - assert.deepEqual(calls, ['user-1']); + assert.deepEqual(calls, [{ userId: 'user-1', options: { listSync: true } }]); resolvers.shift()?.({ created: 0, updated: 0, skipped: 0 }); await first; currentTime += 1_000; assert.equal(scheduler('user-1'), null); - assert.deepEqual(calls, ['user-1']); + assert.deepEqual(calls, [{ userId: 'user-1', options: { listSync: true } }]); currentTime += 30_000; const third = scheduler('user-1'); await Promise.resolve(); - assert.deepEqual(calls, ['user-1', 'user-1']); + assert.deepEqual(calls, [ + { userId: 'user-1', options: { listSync: true } }, + { userId: 'user-1', options: { listSync: true } }, + ]); resolvers.shift()?.({ created: 0, updated: 0, skipped: 0 }); await third; }); From 63f1b8ee440be6419deda585b031819d3a6121b7 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 16 Sep 2026 09:00:59 +0800 Subject: [PATCH 3/3] test(portal): expect /learn static route in delivery order Align static delivery route inventory with the learn standalone pages mount so fast-release guard tests pass on feature/learn-standalone-pages. Co-authored-by: Cursor --- server/portal-static-delivery-routes.test.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/portal-static-delivery-routes.test.mjs b/server/portal-static-delivery-routes.test.mjs index c4aeffc..85dea33 100644 --- a/server/portal-static-delivery-routes.test.mjs +++ b/server/portal-static-delivery-routes.test.mjs @@ -181,6 +181,7 @@ test('registers static delivery routes and middleware in legacy order', () => { ['use', '/plaza-covers'], ['use', '/brand'], ['use', '/assets'], + ['use', '/learn'], ['get', '/dev/wechat-share-preview'], ['use', '/dev'], [ @@ -206,6 +207,10 @@ test('registers static delivery routes and middleware in legacy order', () => { '/project/public/assets', { maxAge: '1d' }, ], + [ + '/project/public/learn', + { maxAge: '5m', index: 'index.html' }, + ], ['/project/public/dev', { maxAge: 0 }], [ '/project/dist',