diff --git a/.github/workflows/package-win-manual.yml b/.github/workflows/package-win-manual.yml index 4cdc6ef..f76d020 100644 --- a/.github/workflows/package-win-manual.yml +++ b/.github/workflows/package-win-manual.yml @@ -49,12 +49,6 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Download verified OpenMAIC player artifact - env: - LEARNING_PLAYER_ARTIFACT_URL: ${{ secrets.LEARNING_PLAYER_ARTIFACT_URL }} - LEARNING_PLAYER_ARTIFACT_SHA256: ${{ secrets.LEARNING_PLAYER_ARTIFACT_SHA256 }} - run: node scripts/download-learning-player-artifact.mjs - - name: Download uv binaries for Windows run: pnpm run uv:download:win diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a624724..6854369 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,12 +60,6 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Download verified OpenMAIC player artifact - env: - LEARNING_PLAYER_ARTIFACT_URL: ${{ secrets.LEARNING_PLAYER_ARTIFACT_URL }} - LEARNING_PLAYER_ARTIFACT_SHA256: ${{ secrets.LEARNING_PLAYER_ARTIFACT_SHA256 }} - run: node scripts/download-learning-player-artifact.mjs - - name: Download uv binaries for macOS if: matrix.platform == 'mac' run: pnpm run uv:download:mac diff --git a/.github/workflows/win-build-test.yml b/.github/workflows/win-build-test.yml index c925c8d..ca3060e 100644 --- a/.github/workflows/win-build-test.yml +++ b/.github/workflows/win-build-test.yml @@ -34,12 +34,6 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Download verified OpenMAIC player artifact - env: - LEARNING_PLAYER_ARTIFACT_URL: ${{ secrets.LEARNING_PLAYER_ARTIFACT_URL }} - LEARNING_PLAYER_ARTIFACT_SHA256: ${{ secrets.LEARNING_PLAYER_ARTIFACT_SHA256 }} - run: node scripts/download-learning-player-artifact.mjs - - name: Download uv binaries for Windows run: pnpm run uv:download:win diff --git a/.gitignore b/.gitignore index 929d1ab..4a52f2d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,6 @@ out/ # Downloaded desktop runtimes resources/bin/ resources/python/ -resources/learning-player/ # Test output coverage/ diff --git a/.project-docs/30-worklog/tasks/20260819-learning-project-catalog-impl-4e9c71a2.md b/.project-docs/30-worklog/tasks/20260819-learning-project-catalog-impl-4e9c71a2.md new file mode 100644 index 0000000..67ac1e9 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260819-learning-project-catalog-impl-4e9c71a2.md @@ -0,0 +1,64 @@ +# Task: Implement Learning project catalog + +## Identity + +- Task ID: 20260819-learning-project-catalog-impl-4e9c71a2 +- Mode: Feature +- Branch: codex/20260819-learning-project-catalog-impl-4e9c71a2-learning-project-catalog-impl +- Worktree: D:\Datas\OthersProjects\makelore-learning-project-catalog-impl-4e9c71a2 +- Base commit: 2cb8a7aef490b118a0968a5a77893f0cf4d3ebea +- Owner: codex +- Status: Ready for integration + +## Scope + +- Replace AI Learning's course catalog/generation/player product with a curated project catalog, project README detail, controlled README media, and verified user-selected ZIP download. +- Preserve the existing login and `module_access.learning` entry policy while replacing the Learning Renderer/Main contract without a legacy compatibility path. +- Remove obsolete course UI, DTOs, IPC, Main services, OpenMAIC player packaging hooks, and focused tests. +- Update the product README and add the Works Square operations/API contract because that backend source is not present in this repository. + +## Intent And Constraints + +- Renderer access remains behind `src/lib/host-api.ts`; Works credentials, arbitrary upstream URLs, object-storage credentials, and local filesystem paths stay Main-owned. +- README images must render only through HTTPS or fixed server-controlled project-media paths; server-controlled media is fetched through a bounded Main proxy and projected as a validated data URL. +- Project downloads use a native save dialog, stream to a temporary file, enforce declared byte size, SHA-256, ZIP signature, redirect/origin, and 512 MiB limits, then rename atomically. +- The new client never reads or migrates previously downloaded course data. It does not delete that historical user data automatically. +- Keep the Makelore light visual system and Chinese-only product copy. Do not restore Works gallery, publishing, or client-side upload workbench behavior. +- Operations backend implementation and deployment are external to this repository; do not claim them complete. + +## Plan + +1. Replace the shared Learning contract and Main Host API routes with project list/detail/media/download endpoints. +2. Rebuild the Learning Renderer as a project card grid plus Markdown detail page and simplify the Learning sidebar. +3. Delete old generation/player/local-library/runtime/IPC/package-artifact code and update app routing/layout. +4. Replace obsolete focused tests, update README and publish a precise Works Square admin/API contract. +5. Run focused tests, typecheck, lint, production build, then the task documentation gate. + +## Outcome + +- Replaced the Learning course/generation/player surface with an authenticated, permission-gated project catalog and `/learning/project/:projectId` README detail route. +- Added strict shared DTOs plus Main-owned Host API projection for project list, detail, bounded media proxy, and native ZIP download. Downloads are streamed to a temporary file and checked for account ownership, origin/redirect policy, size, ZIP signature, and SHA-256 before atomic rename. +- Added Markdown rendering with raw HTML disabled. README images are restricted to server-controlled project media paths; the external server contract requires operations to mirror remote HTTPS images at publish time. +- Removed the obsolete course library, generation, speech, runtime bridge, player server, package consumer, Learning IPC/preload channels, player artifact scripts, packaging resources, CI artifact steps, routes, and tests. Historical downloaded course data is neither read nor automatically deleted. +- Updated the product README and added `docs/learning-project-catalog-server-contract.md` for the external Works Square operations menu, upload fields, publishing validation, image mirroring, and API contract. That external backend/admin implementation is intentionally not claimed as complete in this repository. + +## Verification + +- `pnpm exec vitest run tests/unit/learning-client.test.ts tests/unit/learning-route.test.ts tests/unit/learning-project-download.test.ts tests/unit/learning-page.test.tsx tests/unit/learning-sidebar.test.tsx tests/unit/main-layout-module-gate.test.tsx tests/unit/app-module-provider-gate.test.tsx` — passed, 7 files / 47 tests after the final path-hardening change. +- `pnpm run typecheck` — passed. +- `pnpm test` — passed, 175 files / 2057 tests. +- `pnpm run lint:check` — passed with 0 errors and 6 pre-existing warnings outside this change. +- `pnpm run build:vite` — passed after the final change; existing bundle-size and mixed static/dynamic import warnings remain. +- `pnpm run test:e2e -- tests/e2e/main-navigation.spec.ts` — passed, 3 tests. +- `git diff --check` — passed; Git only reported repository line-ending conversion warnings. + +## Follow-ups + +- Implement and deploy the operations admin/API contract in the external Works Square backend, then smoke-test list, README media, and ZIP download with a real account and published project. +- Decide separately whether a future maintenance release should offer an explicit user-controlled cleanup of historical downloaded course data; this task intentionally preserves it for recoverability. + +## Promotion Candidates + +- Target: `.project-docs/20-architecture/system-overview.md` and `.project-docs/20-architecture/data-flow.md`. Proposal: replace the Learning course/player architecture with the project catalog, bounded media proxy, and verified native download boundary. Evidence: implementation and focused/full verification above. Future impact: future Learning work must use the Host API and must not restore Renderer-held Works credentials or arbitrary download URLs. Semantic conflict: existing canonical Learning architecture still describes the removed player path. Human confirmation required: yes, during integration. +- Target: `.project-docs/40-domain/business-rules.md` and `.project-docs/00-brief/success-criteria.md`. Proposal: record Learning as a curated project catalog whose README images are mirrored by the server and whose downloads are user-selected ZIP files. Evidence: `README.md`, `docs/learning-project-catalog-server-contract.md`, and the new tests. Future impact: operations/backend and client changes share one publish/download contract. Semantic conflict: existing course-oriented rules are superseded. Human confirmation required: yes, during integration. +- Target: `.project-docs/80-commitments/commitments.md`. Proposal: record the external Works Square backend/admin implementation and real-account smoke test as an open integration commitment. Evidence: backend source is absent from this repository. Future impact: prevents treating the client-only delivery as an end-to-end production rollout. Semantic conflict: none known. Human confirmation required: yes, during integration. diff --git a/README.md b/README.md index 0e41195..83959af 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人 - `Makelore Code|AI 编程`:管理本地项目、项目 Agent、会话、文件上下文、代码变更和运行时。 - `Makelore Canvas|AI 绘画`:以设计项目(Workspace)组织 Agent 对话、方向确认、文生图、单参考图生图、视频生成任务和私有结果;参考图可从当前项目作品选择或从本地上传。Canvas 侧栏在“新建设计项目”上方提供“获取灵感”,进入服务端驱动的提示词博物馆。 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 -- `Makelore Learning|AI 学习`:浏览和下载 Works 课程,也可以从需求、材料与多媒体选项发起后台单课生成;下载后的 frozen 课程包由内置 OpenMAIC production Stage 播放。 +- `Makelore Learning|AI 学习`:浏览运营精选的学习项目,阅读项目 README,并把经过完整性校验的 ZIP 源码包保存到电脑继续实践。 应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号的四模块开关并只向 Renderer 投影布尔策略;被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 @@ -27,7 +27,7 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人 - AI 绘画:每个设计项目固定一个设计 Agent,并可包含多条互相独立的设计会话。消息、Brief、Quote 和 `turnRevision` 属于 Conversation;图片/视频生成任务与资产属于 Workspace,切换或新建会话不会创建新项目,也不会隐藏项目任务。图片创作既支持文生图,也支持从当前项目已完成作品或本地上传中选择一张参考图继续生成;输入框支持直接上传参考图,上传后可在候选区点击或输入 `@` 选择,并将所选资产随消息提交;视频沿用同一单图选择器绑定首帧。每条 Conversation 复用服务端持久 Agent Gateway Session;已连接时 Agent 命令、流式回复和任务进度共用 WebSocket,断流时使用幂等 REST 提交与低频同步。确认栏展示并允许编辑服务端最终提示词和 generation options,分别展示清晰度、画幅和视频时长;每次修改都会按当前 Quote 重新报价、刷新设计点,报价完成前不能确认,确认时将最新提示词与参数原值提交给后端。客户端不计算百炼尺寸、供应商价格或积分价格,服务端 Quote 是唯一计费准则。确认结果会按 Quote 对账,即使 Run 在任务落库后异常结束,Workspace 任务仍会恢复到统一列表。生产环境使用 Works Square 云端 Workspace 契约,上游不可用时明确报错。 - AI 绘画项目栏会在当前项目下保留会话历史,默认显示最近五条消息摘要和更新时间,更多会话可展开;新建或点击历史条目都在同一项目中切换并恢复完整对话。 - AI 绘画项目支持从侧栏删除。确认删除时必须完整输入项目名称;删除后项目及其会话、任务、参考图和生成作品会从账户中隐藏且无法访问,不影响用户已另存到磁盘的副本。服务端采用软删除,数据库记录和对象存储暂不物理清理。未提交的任务会被取消并释放预留积分,已提交或运行中的任务继续后台结算但对用户隐藏;删除当前项目后自动打开最近更新的剩余项目,删除最后一个项目后进入空状态。 -- AI 学习:默认主区直接展示课程广场,生成课程从全局左栏打开宽工作台;无材料请求使用 JSON,有材料请求使用严格有界 multipart(最多 5 个、单个 50 MiB、总计 150 MiB,需求最多 4,000 字)。大课在课程广场保持一张课程卡,进入后按有序模块切换同一 production Stage。课程包最大 512 MiB,先校验大小与 SHA-256 再原子安装;课程媒体只允许与 MIME 匹配的被动图片、音频、视频和字体类型,同源 HTML/SVG/XML/脚本/PDF 等在安装与服务阶段都被拒绝。本地课程按 Main 确认的账号隔离,Agent、ASR、PBL 与主观题评分通过 Main 白名单桥接并在离线或无权益时明确提示。 +- AI 学习:主区展示服务端分页项目卡片,详情页用安全 Markdown 渲染 README;原始远程图片由 Works Square 在发布时校验、镜像并改写为固定媒体路径,再由 Main 进行 Bearer 代理、可信 raster MIME 与 10 MiB 上限校验。下载按钮打开系统保存对话框,Main 流式校验声明大小、SHA-256 与 ZIP 签名后原子保存,单包最大 512 MiB。客户端不提供课程生成、课程播放器、本地课程库、Agent、ASR 或课堂 runtime。运营管理与接口字段见 [`docs/learning-project-catalog-server-contract.md`](docs/learning-project-catalog-server-contract.md)。 - 提示词博物馆:只陈列经过审核的作品预览、Prompt、分类以及作者/来源/许可证信息,支持搜索、使用场景/风格/主体筛选和详情抽屉;“使用此 Prompt”只把原文带回当前 Canvas 会话输入框,不自动发送、不构成社区。列表和详情数据由服务端提供,客户端不打包数据集;服务端字段契约见 [`docs/prompt-museum-server-contract.md`](docs/prompt-museum-server-contract.md)。 - 视觉系统:单一浅色主题,品牌蓝 `#3A5578`、星火橙 `#F26A3D`、白色画布与低饱和蓝灰层级。 - 字体系统:Renderer UI 内嵌 Inter Variable 与经过字符子集化的 Source Han Sans SC WOFF2,按字符范围统一中英文并保留系统中文字体 fallback;代码、路径和日志使用独立等宽字体。 @@ -75,7 +75,7 @@ pnpm run package:win pnpm run package:linux ``` -Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode 运行时资源,产物写入忽略的 `release/` 目录。正式发布还必须通过 `MAKELORE_LEARNING_PLAYER_ARTIFACT` 提供已解压且通过清单校验的 OpenMAIC 播放器产物;CI 先按固定 URL 与 SHA-256 下载,未提供或校验失败会立即停止,安装包不依赖相邻源码仓库或被忽略的本机构建目录。Windows 正式包中的 OpenCode、Playwright MCP、Python 和 uv 均从安装目录解析;缺少本地资源时启动或产物验证会直接失败,不会回退到系统 Python、npm 或 npx 下载。Git、项目编译器和用户选择的浏览器仍属于项目/系统工具,不属于内置 OpenCode 运行时。macOS 与 Linux 的双架构产物需要分别完成对应架构的 staging 与产物验证后再发布。 +Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode 运行时资源,产物写入忽略的 `release/` 目录。AI 学习不再携带独立播放器产物。Windows 正式包中的 OpenCode、Playwright MCP、Python 和 uv 均从安装目录解析;缺少本地资源时启动或产物验证会直接失败,不会回退到系统 Python、npm 或 npx 下载。Git、项目编译器和用户选择的浏览器仍属于项目/系统工具,不属于内置 OpenCode 运行时。macOS 与 Linux 的双架构产物需要分别完成对应架构的 staging 与产物验证后再发布。 ## 代码结构 @@ -99,7 +99,7 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode - AI 编程项目配置以项目内 `.niancode/project.json` 为准;项目文件和会话主数据保持本地,问答观察快照按个人资料同步规则单向上行。 - AI 绘画 Renderer 只调用 Main-owned Host API;Main 负责 Works Square Token 刷新、Conversation 所属的服务端持久 Agent Session、单次 WebSocket ticket、双向命令/事件帧、断点续传与契约映射,并通过本机 Host API 的 SSE 投影同步任务状态。切换会话只重连对应流;注销或退出时关闭本地流并清除本机 Session-id 缓存,不删除服务端持久 Conversation Session。远端 Token 与 ticket 不进入 Renderer。 - AI 绘画使用独立的云端 Workspace 边界,不回退到 AI 编程项目数据,也不向 Renderer 暴露 Provider、模型、Prompt、存储 URI 或远端登录 Token。 -- AI 学习的课程目录、生成、进度、Agent、ASR 与课堂 runtime 都经过 Main-owned 边界;Renderer 不持有 Works Token、模型、Provider 配置或本地 archive 路径。Main 以登录身份派生不透明账号分区并拒绝切换后的迟到结果;课程下载重定向只允许同一 Works origin,播放器使用账号绑定的 loopback origin 与短效 nonce。只有显式 `readClassroom` 播放路径可以注册课程资源,Agent/runtime 会先验证调用前已存在的 active player registration,再做无副作用权威解析;课程不能靠请求自身完成注册。云端绑定始终使用课程 aggregate `contentHash`,模块 id/hash 仅作为受控上下文;课堂 runtime 只允许固定能力路径,播放器不能自报课程身份或代理任意 URL。 +- AI 学习只通过 Main-owned Host API 获取项目列表、README 详情和固定媒体路径;Renderer 不持有 Works Token、对象存储地址、任意下载 URL 或本地文件路径。项目 ZIP 只允许同 Works origin 最多五跳重定向,重定向请求不携带 Bearer;下载结果仅向 Renderer 返回 `saved` 或 `cancelled`。README 不执行原始 HTML,图片必须由服务端镜像为固定项目媒体路径。 - Prompt Museum 使用独立的 Main-owned Host API 代理;Renderer 只接收分页卡片、详情和服务端返回的图片地址,Works Square Token 只由 Main 持有。发布记录必须由服务端完成作者、来源、许可证和素材授权审核,模块不提供投稿、点赞、评论或排行榜。 - AI 编程的 Agent 配置是项目所有的;稳定 id 用于保持会话兼容,显示名称可以修改。AI 绘画的设计 Agent 是固定产品能力,不作为用户可增删的项目实体。 diff --git a/docs/learning-project-catalog-server-contract.md b/docs/learning-project-catalog-server-contract.md new file mode 100644 index 0000000..7410c9a --- /dev/null +++ b/docs/learning-project-catalog-server-contract.md @@ -0,0 +1,136 @@ +# AI 学习项目目录服务端协作契约 + +> 状态:Makelore 客户端与 Electron Main 已按本契约实现;运营后台、数据库、对象存储与生产数据不在本仓库内,需在 Works Square 侧实现并部署后联调。 + +## 1. 产品与权限边界 + +AI 学习是登录后可用的精选项目目录。V1 只包含项目列表、README 详情和 ZIP 下载,不包含课程生成、课程播放器、本地课程库、学习进度、Agent、ASR、评分或课堂 runtime,也不兼容旧课程接口。 + +沿用现有账号与 `module_access.learning` 策略:客户端入口策略在页面初始化前拦截无权限账号,Works Square 的列表、详情、媒体和下载接口仍必须独立校验登录态及 Learning 权限,不能依赖客户端置灰。 + +Renderer 的固定链路为 `Renderer -> Host API -> Electron Main -> Works Square`。Renderer 不得获得 Works Token、对象存储凭据、内部存储 key、任意下载 URL或本地保存路径。 + +## 2. 运营后台“学习项目管理” + +运营后台新增一级菜单“学习项目管理”,至少支持新建、编辑、预览、发布、下架、排序和查看发布记录。项目字段如下: + +| 字段 | 规则 | +| --- | --- | +| 项目名称 `name` | 必填,1–200 字符 | +| 摘要 `summary` | 必填,1–2,000 字符,用于卡片 | +| 封面 `cover` | 必填,PNG/JPEG/WebP,最大 10 MiB;服务端读取真实 MIME、尺寸并生成受控媒体 | +| 项目压缩包 `archive` | 必填 ZIP,最大 512 MiB;服务端流式计算字节数与 SHA-256,并验证 ZIP 签名 | +| README `readme` | 必填 `.md`,UTF-8,最大 500 KiB;不接受可执行 HTML 作为发布内容 | +| 标签 `tags` | 0–16 个,每项 1–64 字符,去重 | +| 版本 `version` | 可空,最大 64 字符,仅作展示 | +| 排序 `sort_order` | 有界整数;列表默认按运营排序,再按发布时间稳定排序 | +| 状态 `status` | `draft`、`published`、`archived` | + +上传中的对象不能直接进入公开目录。发布必须在一个事务/发布代际中冻结元数据、README、封面、镜像图片和 ZIP 摘要;任一校验失败则整个发布失败,旧的已发布版本继续可读。下架后列表和详情立即不可见,但已有审计记录不能物理删除。 + +后台必须记录操作人、时间、发布代际、变更摘要、归档 SHA-256/字节数和远程图片抓取结果。客户端不提供任何运营上传或发布入口。 + +## 3. README 远程图片发布规则 + +README 中的远程图片在“发布”时由服务端解析 Markdown AST 并镜像,客户端不直接使用原始远程图片 URL。处理要求: + +1. 只接受无用户名/密码的 HTTPS URL;拒绝 `http:`、`data:`、`file:`、本地路径和协议相对地址。 +2. 每一跳重新解析 DNS,并拒绝 loopback、私网、链路本地、保留地址、云元数据地址和非公网目标;最多 5 次重定向。 +3. 单图最大 10 MiB,同时限制超时、并发数和 README 图片总量;响应必须是实际可解码的 PNG/JPEG/WebP/GIF/AVIF,拒绝 SVG、HTML、XML 和 MIME 欺骗。 +4. 将通过校验的字节写入受控对象存储,以内容摘要去重;发布记录引用不可变对象。 +5. 把 Markdown 图片地址改写为 `/api/learning/projects/:projectId/media/:mediaId`。详情接口只返回改写后的 Markdown,不返回原始远程 URL或对象 key。 +6. Markdown 原始 HTML在客户端被禁用;服务端也应在预览与发布时提示被忽略的 HTML,避免运营误判展示效果。 + +封面同样优先返回固定媒体路径。若返回 HTTPS CDN 地址,该地址必须无凭据、由 Works Square 控制且不包含用户隐私。 + +## 4. 客户端公开接口 + +| 方法 | Works Square 路径 | 用途 | +| --- | --- | --- | +| `GET` | `/api/learning/projects?cursor=&limit=` | 已发布项目分页列表 | +| `GET` | `/api/learning/projects/:id` | 项目详情和 README | +| `GET` | `/api/learning/projects/:id/media/:mediaId` | 受控封面/README raster 图片 | +| `GET` | `/api/learning/projects/:id/archive` | ZIP 字节流或同 Works origin 重定向 | + +`limit` 为 1–48,默认 24;`cursor` 是不透明游标。空列表返回 `200`,不要用 `404`。列表不返回 `readmeMarkdown`、`archiveSha256`、`archiveFileName` 或任何存储字段。 + +### 列表响应 + +```json +{ + "success": true, + "data": { + "items": [ + { + "id": "robot-arm", + "name": "桌面机械臂", + "summary": "从零搭建一个可以抓取积木的桌面机械臂。", + "cover": { + "url": "/api/learning/projects/robot-arm/media/cover", + "alt": "桌面机械臂成品", + "width": 1600, + "height": 900 + }, + "tags": ["机器人", "Python"], + "version": "1.2.0", + "archiveBytes": 12582912, + "publishedAt": "2026-08-01T00:00:00Z", + "updatedAt": "2026-08-18T00:00:00Z" + } + ], + "nextCursor": null, + "total": 1 + } +} +``` + +### 详情响应 + +详情复用全部列表字段,并增加: + +```json +{ + "success": true, + "data": { + "id": "robot-arm", + "name": "桌面机械臂", + "summary": "从零搭建一个可以抓取积木的桌面机械臂。", + "cover": { + "url": "/api/learning/projects/robot-arm/media/cover", + "alt": "桌面机械臂成品", + "width": 1600, + "height": 900 + }, + "tags": ["机器人", "Python"], + "version": "1.2.0", + "archiveBytes": 12582912, + "publishedAt": "2026-08-01T00:00:00Z", + "updatedAt": "2026-08-18T00:00:00Z", + "readmeMarkdown": "# 桌面机械臂\n\n![接线图](/api/learning/projects/robot-arm/media/wiring)", + "archiveFileName": "makelore-robot-arm-1.2.0.zip", + "archiveSha256": "64位小写十六进制SHA-256" + } +} +``` + +TypeScript 权威字段定义位于 [`shared/learning.ts`](../shared/learning.ts)。未列出的内部字段会被 Main 丢弃。 + +### 媒体与归档响应 + +- 媒体接口只返回受控 raster 内容,必须设置准确 `Content-Type` 与 `Content-Length`;Main 限制 10 MiB,并转换为 data URL给 Renderer。 +- 归档接口返回 `application/zip`、`application/x-zip-compressed` 或 `application/octet-stream`,设置准确 `Content-Length`。如需重定向,只能跳转到与 Works API 相同 origin 的 HTTP(S) 地址,最多 5 跳;Main 不向重定向目标转发 Bearer。 +- 归档字节必须与详情中的 `archiveBytes` 和 `archiveSha256` 精确一致。客户端验证失败时删除临时文件,不留下部分下载。 + +## 5. 错误、缓存与上线顺序 + +统一使用现有 `{ success, status, code, error }` 错误封装。至少支持: + +- `LEARNING_AUTH_REQUIRED`(401); +- `LEARNING_FORBIDDEN`(403); +- `LEARNING_PROJECT_NOT_FOUND`(404); +- `LEARNING_CONFLICT`(409,发布代际变化); +- `LEARNING_UNAVAILABLE`(429/502/503)。 + +列表/详情可对发布代际生成 ETag;媒体和归档按内容摘要设置不可变缓存,但不得缓存带用户私有授权的响应到公共共享缓存。日志不得记录 Bearer、签名 URL、README 原始私有 URL或对象存储 key。 + +上线顺序:先部署数据库/对象存储、运营后台、远程图片镜像和四个公开接口,并用真实账号完成发布/下架/图片/ZIP 联调;再发布新客户端。旧 `/api/learning/courses`、generation/progress/runtime 接口不在新客户端兼容范围内,可按服务端消费者盘点结果独立退役。 diff --git a/electron-builder.yml b/electron-builder.yml index 299fb7b..41e5e52 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -24,13 +24,7 @@ extraResources: - "!icons/*.svg" - "!bin/**" - "!python/**" - - "!learning-player/**" - "!screenshot/**" - # Verified, versioned production Stage artifact emitted by sync:learning-player. - - from: build/learning-player/ - to: resources/learning-player/ - filter: - - "**/*" # opencode runtime bundled by scripts/bundle-opencode.mjs. - from: build/opencode-ai/ to: opencode-ai/ diff --git a/electron/api/routes/learning.ts b/electron/api/routes/learning.ts index d90d51e..157fe20 100644 --- a/electron/api/routes/learning.ts +++ b/electron/api/routes/learning.ts @@ -1,78 +1,98 @@ +import { app, dialog, type SaveDialogOptions } from 'electron'; import type { IncomingMessage, ServerResponse } from 'node:http'; +import { join } from 'node:path'; +import { + LEARNING_ARCHIVE_MAX_BYTES, + LEARNING_MEDIA_MAX_BYTES, + type LearningProjectDetail, +} from '../../../shared/learning'; +import { + LearningProjectDownloadError, + safeLearningProjectArchiveFileName, + saveLearningProjectArchive, +} from '../../services/learning-project-download'; +import { + getValidWorksSquareAccessToken, + getWorksSquareAccountBinding, + isCurrentWorksSquareAccountBinding, + type WorksSquareAccountBinding, +} from '../../services/works-square-session'; +import { proxyAwareFetch } from '../../utils/proxy-fetch'; import type { HostApiContext } from '../context'; import { sendJson } from '../route-utils'; import { WORKS_SQUARE_CONFIG } from '../works-config'; -import { getValidWorksSquareAccessToken } from '../../services/works-square-session'; -import { proxyAwareFetch } from '../../utils/proxy-fetch'; -const LOCAL_ROOT = '/api/works/learning'; -const UPSTREAM_ROOT = '/api/learning'; -const COURSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; -const JOB_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; -const MODULE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/; +const LOCAL_ROOT = '/api/works/learning/projects'; +const UPSTREAM_ROOT = '/api/learning/projects'; +const PROJECT_ID = '[A-Za-z0-9][A-Za-z0-9._-]{0,127}'; +const MEDIA_ID = '[A-Za-z0-9][A-Za-z0-9._-]{0,127}'; +const PROJECT_ID_PATTERN = new RegExp(`^${PROJECT_ID}$`); const SHA256_PATTERN = /^[0-9a-f]{64}$/; -const MAX_REQUEST_BYTES = 16 * 1024; +const MEDIA_URL_PATTERN = new RegExp(`^/api/learning/projects/${PROJECT_ID}/media/${MEDIA_ID}$`); +const LOCAL_MEDIA_PATH_PATTERN = new RegExp(`^${LOCAL_ROOT}/(${PROJECT_ID})/media/(${MEDIA_ID})$`); +const LOCAL_DOWNLOAD_PATH_PATTERN = new RegExp(`^${LOCAL_ROOT}/(${PROJECT_ID})/download$`); +const LOCAL_DETAIL_PATH_PATTERN = new RegExp(`^${LOCAL_ROOT}/(${PROJECT_ID})$`); +const MAX_LIST_ITEMS = 48; +const MAX_TAGS = 16; const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; -const MAX_COURSES = 500; -const MAX_PROGRESS_ITEMS = 5_000; -const MAX_MODULES = 500; +const TRUSTED_MEDIA_MIME_TYPES = new Set([ + 'image/avif', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/webp', +]); + +type LearningRoute = + | { kind: 'list'; upstreamPath: string } + | { kind: 'detail'; upstreamPath: string; projectId: string } + | { kind: 'media'; upstreamPath: string; projectId: string } + | { kind: 'download'; upstreamPath: string; projectId: string }; type Dependencies = { fetchImpl?: typeof fetch; getAccessToken?: typeof getValidWorksSquareAccessToken; + getAccountBinding?: () => WorksSquareAccountBinding | null; + isCurrentAccountBinding?: (value: WorksSquareAccountBinding) => boolean; apiBaseUrl?: string; + chooseDestination?: (ctx: HostApiContext, fileName: string) => Promise; + saveArchive?: typeof saveLearningProjectArchive; }; -type LearningRoute = - | { kind: 'course-list'; upstreamPath: string } - | { kind: 'course-detail'; upstreamPath: string; courseId: string } - | { kind: 'progress-list'; upstreamPath: string } - | { kind: 'progress-write'; upstreamPath: string; courseId: string } - | { kind: 'generation-start'; upstreamPath: string } - | { kind: 'generation-read'; upstreamPath: string; jobId: string } - | { kind: 'generation-control'; upstreamPath: string; jobId: string } - | { kind: 'generation-finalize'; upstreamPath: string; jobId: string }; - class InvalidLearningDataError extends Error {} -function matchLearningRoute(pathname: string): LearningRoute | null { - if (pathname === `${LOCAL_ROOT}/courses` || pathname === `${LOCAL_ROOT}/courses/mine`) { - return { kind: 'course-list', upstreamPath: `${UPSTREAM_ROOT}${pathname.slice(LOCAL_ROOT.length)}` }; +function matchRoute(pathname: string): LearningRoute | null { + if (pathname === LOCAL_ROOT) return { kind: 'list', upstreamPath: UPSTREAM_ROOT }; + const media = LOCAL_MEDIA_PATH_PATTERN.exec(pathname); + if (media) { + return { + kind: 'media', + projectId: media[1], + upstreamPath: `${UPSTREAM_ROOT}/${media[1]}/media/${media[2]}`, + }; } - if (pathname === `${LOCAL_ROOT}/progress`) { - return { kind: 'progress-list', upstreamPath: `${UPSTREAM_ROOT}/progress` }; + const download = LOCAL_DOWNLOAD_PATH_PATTERN.exec(pathname); + if (download) { + return { + kind: 'download', + projectId: download[1], + upstreamPath: `${UPSTREAM_ROOT}/${download[1]}`, + }; } - if (pathname === `${LOCAL_ROOT}/generations`) { - return { kind: 'generation-start', upstreamPath: `${UPSTREAM_ROOT}/generations` }; - } - - const courseMatch = pathname.match(/^\/api\/works\/learning\/courses\/([^/]+?)(\/progress)?$/); - if (courseMatch) { - const courseId = courseMatch[1]; - if (!COURSE_ID_PATTERN.test(courseId)) return null; - return courseMatch[2] - ? { kind: 'progress-write', upstreamPath: `${UPSTREAM_ROOT}/courses/${courseId}/progress`, courseId } - : { kind: 'course-detail', upstreamPath: `${UPSTREAM_ROOT}/courses/${courseId}`, courseId }; - } - - const generationMatch = pathname.match(/^\/api\/works\/learning\/generations\/([^/]+?)(\/(?:cancel|resume|finalize))?$/); - if (!generationMatch) return null; - const jobId = generationMatch[1]; - if (!JOB_ID_PATTERN.test(jobId)) return null; - const suffix = generationMatch[2] ?? ''; - const upstreamPath = `${UPSTREAM_ROOT}/generations/${jobId}${suffix}`; - if (suffix === '/finalize') return { kind: 'generation-finalize', upstreamPath, jobId }; - if (suffix) return { kind: 'generation-control', upstreamPath, jobId }; - return { kind: 'generation-read', upstreamPath, jobId }; + const detail = LOCAL_DETAIL_PATH_PATTERN.exec(pathname); + return detail + ? { kind: 'detail', projectId: detail[1], upstreamPath: `${UPSTREAM_ROOT}/${detail[1]}` } + : null; } -function allowedQuery(url: URL, route: LearningRoute): string { - if (route.kind !== 'course-list' && route.kind !== 'progress-list') return ''; +function listQuery(url: URL): string { const query = new URLSearchParams(); - const limit = url.searchParams.get('limit'); - if (limit && /^\d{1,3}$/.test(limit) && Number(limit) <= 500) query.set('limit', limit); - const offset = url.searchParams.get('offset'); - if (offset && /^\d{1,7}$/.test(offset)) query.set('offset', offset); + const cursor = url.searchParams.get('cursor')?.trim(); + if (cursor && cursor.length <= 1024) query.set('cursor', cursor); + const limit = url.searchParams.get('limit')?.trim(); + if (limit && /^\d{1,2}$/.test(limit) && Number(limit) >= 1 && Number(limit) <= MAX_LIST_ITEMS) { + query.set('limit', limit); + } const encoded = query.toString(); return encoded ? `?${encoded}` : ''; } @@ -82,21 +102,15 @@ function asRecord(value: unknown): Record { return value as Record; } -function boundedArray(value: unknown, maximum: number): unknown[] { - if (!Array.isArray(value) || value.length > maximum) throw new InvalidLearningDataError(); - return value; +function boundedString(value: unknown, maximum: number): string { + if (typeof value !== 'string') throw new InvalidLearningDataError(); + const normalized = value.trim(); + if (!normalized || normalized.length > maximum) throw new InvalidLearningDataError(); + return normalized; } -function boundedString(value: unknown, maximum: number, allowEmpty = false): string { - if (typeof value !== 'string' || value.length > maximum || (!allowEmpty && !value.trim())) { - throw new InvalidLearningDataError(); - } - return value; -} - -function nullableString(value: unknown, maximum: number, allowEmpty = true): string | null { - if (value === null) return null; - return boundedString(value, maximum, allowEmpty); +function nullableString(value: unknown, maximum: number): string | null { + return value === null ? null : boundedString(value, maximum); } function boundedInteger(value: unknown, minimum: number, maximum: number): number { @@ -106,276 +120,158 @@ function boundedInteger(value: unknown, minimum: number, maximum: number): numbe return value as number; } -function boundedNumber(value: unknown, minimum: number, maximum: number): number { - if (typeof value !== 'number' || !Number.isFinite(value) || value < minimum || value > maximum) { - throw new InvalidLearningDataError(); - } - return value; -} - -function nullableInteger(value: unknown, minimum: number, maximum: number): number | null { - return value === null ? null : boundedInteger(value, minimum, maximum); -} - function isoTimestamp(value: unknown): string { const timestamp = boundedString(value, 64); if (!Number.isFinite(Date.parse(timestamp))) throw new InvalidLearningDataError(); return timestamp; } -function identifier(value: unknown, pattern: RegExp): string { - if (typeof value !== 'string' || !pattern.test(value)) throw new InvalidLearningDataError(); - return value; -} - -function hash(value: unknown): string { - return identifier(value, SHA256_PATTERN); -} - -function projectCapabilities(value: unknown): Record { - const capabilities = asRecord(value); - if (capabilities.modular === true) { - if (capabilities.orderedModules !== true || capabilities.productionStagePerModule !== true) { - throw new InvalidLearningDataError(); - } - return { modular: true, orderedModules: true, productionStagePerModule: true }; - } - if (capabilities.modular !== undefined && capabilities.modular !== false) throw new InvalidLearningDataError(); - const sceneKinds = boundedArray(capabilities.sceneKinds, 4).map((kind) => { - if (!['slide', 'interactive', 'quiz', 'pbl'].includes(String(kind))) throw new InvalidLearningDataError(); - return kind as string; - }); - if (new Set(sceneKinds).size !== sceneKinds.length - || typeof capabilities.hasAudio !== 'boolean' - || typeof capabilities.hasWhiteboard !== 'boolean' - || typeof capabilities.hasAgent !== 'boolean') { - throw new InvalidLearningDataError(); - } - return { - ...(capabilities.modular === false ? { modular: false } : {}), - sceneKinds, - hasAudio: capabilities.hasAudio, - hasWhiteboard: capabilities.hasWhiteboard, - hasAgent: capabilities.hasAgent, - }; -} - -function projectCourseModule(value: unknown): Record { - const module = asRecord(value); - return { - moduleId: identifier(module.moduleId, MODULE_ID_PATTERN), - title: boundedString(module.title, 300), - summary: nullableString(module.summary, 2_000), - sceneCount: boundedInteger(module.sceneCount, 0, 100_000), - contentHash: hash(module.contentHash), - }; -} - -function projectCourse(value: unknown): Record { - const course = asRecord(value); - const origin = boundedString(course.origin, 32); - const status = boundedString(course.status, 32); - if (!['user_single', 'ops_large'].includes(origin) - || !['generating', 'ready', 'published', 'failed', 'archived'].includes(status)) { - throw new InvalidLearningDataError(); - } - const modules = course.modules === undefined - ? undefined - : boundedArray(course.modules, MAX_MODULES).map(projectCourseModule); - if (modules && new Set(modules.map((module) => module.moduleId)).size !== modules.length) { - throw new InvalidLearningDataError(); - } - return { - id: identifier(course.id, COURSE_ID_PATTERN), - origin, - status, - title: boundedString(course.title, 300), - summary: nullableString(course.summary, 2_000), - language: nullableString(course.language, 64), - contentHash: hash(course.contentHash), - archiveSha256: hash(course.archiveSha256), - archiveBytes: boundedInteger(course.archiveBytes, 1, 10 * 1024 * 1024 * 1024), - formatVersion: boundedInteger(course.formatVersion, 1, 1_000), - minPlayerVersion: boundedString(course.minPlayerVersion, 64), - sceneCount: boundedInteger(course.sceneCount, 0, 100_000), - ...(modules === undefined ? {} : { modules }), - capabilities: projectCapabilities(course.capabilities), - createdAt: isoTimestamp(course.createdAt), - publishedAt: course.publishedAt === null ? null : isoTimestamp(course.publishedAt), - }; -} - -function projectProgress(value: unknown): Record { - const progress = asRecord(value); - if (typeof progress.completed !== 'boolean') throw new InvalidLearningDataError(); - const moduleId = progress.moduleId === undefined - ? undefined - : progress.moduleId === null ? null : identifier(progress.moduleId, MODULE_ID_PATTERN); - const moduleContentHash = progress.moduleContentHash === undefined - ? undefined - : progress.moduleContentHash === null ? null : hash(progress.moduleContentHash); - return { - courseId: identifier(progress.courseId, COURSE_ID_PATTERN), - contentHash: hash(progress.contentHash), - ...(moduleId === undefined ? {} : { moduleId }), - ...(moduleContentHash === undefined ? {} : { moduleContentHash }), - sceneOrder: boundedInteger(progress.sceneOrder, 0, 1_000_000), - actionIndex: nullableInteger(progress.actionIndex, 0, 1_000_000), - positionMs: nullableInteger(progress.positionMs, 0, 31_536_000_000), - completed: progress.completed, - updatedAt: isoTimestamp(progress.updatedAt), - }; -} - -function projectGeneration(value: unknown): Record { - const generation = asRecord(value); - const mode = generation.mode === undefined ? undefined : boundedString(generation.mode, 16); - if (mode !== undefined && mode !== 'single' && mode !== 'large') throw new InvalidLearningDataError(); - if (typeof generation.done !== 'boolean') throw new InvalidLearningDataError(); - const contractVersion = generation.contractVersion === undefined - ? undefined - : boundedInteger(generation.contractVersion, 1, 100); - const rawError = generation.error === null ? null : boundedString(generation.error, 4_000); - return { - ...(contractVersion === undefined ? {} : { contractVersion }), - jobId: identifier(generation.jobId, JOB_ID_PATTERN), - status: boundedString(generation.status, 64), - ...(mode === undefined ? {} : { mode }), - step: nullableString(generation.step, 128), - progress: generation.progress === null ? null : boundedNumber(generation.progress, 0, 100), - message: nullableString(generation.message, 2_000), - scenesGenerated: nullableInteger(generation.scenesGenerated, 0, 100_000), - totalScenes: nullableInteger(generation.totalScenes, 0, 100_000), - courseId: generation.courseId === null ? null : identifier(generation.courseId, COURSE_ID_PATTERN), - error: rawError === null ? null : '课程生成失败,请稍后重试', - done: generation.done, - }; -} - -function projectGenerationOptions(value: unknown): Record { - const options = asRecord(value); - const booleanKeys = [ - 'enableWebSearch', - 'enableImageGeneration', - 'enableVideoGeneration', - 'enableTTS', - 'interactiveMode', - 'taskEngineMode', - ] as const; - for (const key of booleanKeys) { - if (typeof options[key] !== 'boolean') throw new InvalidLearningDataError(); - } - return { - requirement: boundedString(options.requirement, 4_000).trim(), - enableWebSearch: options.enableWebSearch, - enableImageGeneration: options.enableImageGeneration, - enableVideoGeneration: options.enableVideoGeneration, - enableTTS: options.enableTTS, - interactiveMode: options.interactiveMode, - taskEngineMode: options.taskEngineMode, - }; -} - -function projectProgressWrite(value: unknown): Record { - const progress = asRecord(value); - if (typeof progress.completed !== 'boolean') throw new InvalidLearningDataError(); - const moduleId = progress.moduleId === undefined - ? undefined - : progress.moduleId === null ? null : identifier(progress.moduleId, MODULE_ID_PATTERN); - return { - contentHash: hash(progress.contentHash), - ...(moduleId === undefined ? {} : { moduleId }), - sceneOrder: boundedInteger(progress.sceneOrder, 0, 1_000_000), - actionIndex: nullableInteger(progress.actionIndex, 0, 1_000_000), - positionMs: nullableInteger(progress.positionMs, 0, 31_536_000_000), - completed: progress.completed, - }; -} - -async function readBoundedJsonBody(req: IncomingMessage): Promise { - const chunks: Buffer[] = []; - let bytes = 0; - for await (const chunk of req) { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - bytes += buffer.byteLength; - if (bytes > MAX_REQUEST_BYTES) throw new InvalidLearningDataError(); - chunks.push(buffer); - } - const raw = Buffer.concat(chunks).toString('utf8').trim(); - if (!raw) throw new InvalidLearningDataError(); +function mediaUrl(value: unknown): string { + const url = boundedString(value, 2048); + if (MEDIA_URL_PATTERN.test(url)) return url; + let parsed: URL; try { - return JSON.parse(raw) as unknown; + parsed = new URL(url); } catch { throw new InvalidLearningDataError(); } + if (parsed.protocol !== 'https:' || parsed.username || parsed.password) throw new InvalidLearningDataError(); + return parsed.toString(); } -async function readBoundedResponseJson(response: Response): Promise { +function projectImage(value: unknown): Record { + const image = asRecord(value); + const width = image.width === undefined ? undefined : boundedInteger(image.width, 1, 32_768); + const height = image.height === undefined ? undefined : boundedInteger(image.height, 1, 32_768); + return { + url: mediaUrl(image.url), + alt: boundedString(image.alt, 500), + ...(width === undefined ? {} : { width }), + ...(height === undefined ? {} : { height }), + }; +} + +function projectSummary(value: unknown): Record { + const project = asRecord(value); + const id = boundedString(project.id, 128); + if (!PROJECT_ID_PATTERN.test(id)) throw new InvalidLearningDataError(); + const tags = Array.isArray(project.tags) && project.tags.length <= MAX_TAGS + ? project.tags.map((tag) => boundedString(tag, 64)) + : (() => { throw new InvalidLearningDataError(); })(); + if (new Set(tags).size !== tags.length) throw new InvalidLearningDataError(); + return { + id, + name: boundedString(project.name, 200), + summary: boundedString(project.summary, 2_000), + cover: projectImage(project.cover), + tags, + version: nullableString(project.version, 64), + archiveBytes: boundedInteger(project.archiveBytes, 1, LEARNING_ARCHIVE_MAX_BYTES), + publishedAt: isoTimestamp(project.publishedAt), + updatedAt: isoTimestamp(project.updatedAt), + }; +} + +function projectDetail(value: unknown): LearningProjectDetail { + const project = asRecord(value); + const summary = projectSummary(project); + const archiveSha256 = boundedString(project.archiveSha256, 64); + if (!SHA256_PATTERN.test(archiveSha256)) throw new InvalidLearningDataError(); + const archiveFileName = boundedString(project.archiveFileName, 160); + if (!archiveFileName.toLowerCase().endsWith('.zip')) throw new InvalidLearningDataError(); + return { + ...summary, + readmeMarkdown: boundedString(project.readmeMarkdown, 500_000), + archiveFileName, + archiveSha256, + } as LearningProjectDetail; +} + +function unwrapEnvelope(value: unknown): unknown { + const envelope = asRecord(value); + if (envelope.success !== true || envelope.data === undefined) throw new InvalidLearningDataError(); + return envelope.data; +} + +function projectPage(value: unknown): Record { + const page = asRecord(unwrapEnvelope(value)); + if (!Array.isArray(page.items) || page.items.length > MAX_LIST_ITEMS) throw new InvalidLearningDataError(); + const nextCursor = page.nextCursor === null ? null : boundedString(page.nextCursor, 1024); + const total = page.total === undefined ? undefined : boundedInteger(page.total, 0, Number.MAX_SAFE_INTEGER); + return { + items: page.items.map(projectSummary), + nextCursor, + ...(total === undefined ? {} : { total }), + }; +} + +async function readBoundedJson(response: Response): Promise { + const declaredLength = Number(response.headers.get('content-length')); + if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) { + await response.body?.cancel().catch(() => undefined); + throw new InvalidLearningDataError(); + } if (!response.body) throw new InvalidLearningDataError(); const reader = response.body.getReader(); const chunks: Uint8Array[] = []; - let bytes = 0; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - bytes += value.byteLength; - if (bytes > MAX_RESPONSE_BYTES) throw new InvalidLearningDataError(); - chunks.push(value); + let size = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_RESPONSE_BYTES) { + await reader.cancel().catch(() => undefined); + throw new InvalidLearningDataError(); } - } finally { - reader.releaseLock(); - } - const combined = new Uint8Array(bytes); - let offset = 0; - for (const chunk of chunks) { - combined.set(chunk, offset); - offset += chunk.byteLength; + chunks.push(value); } try { - return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(combined)) as unknown; + return JSON.parse(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8')) as unknown; } catch { throw new InvalidLearningDataError(); } } -function projectSuccess(route: LearningRoute, payload: unknown): unknown { - switch (route.kind) { - case 'course-list': return boundedArray(payload, MAX_COURSES).map(projectCourse); - case 'course-detail': { - const projected = projectCourse(payload); - if (projected.id !== route.courseId) throw new InvalidLearningDataError(); - return projected; - } - case 'generation-finalize': return projectCourse(payload); - case 'progress-list': return boundedArray(payload, MAX_PROGRESS_ITEMS).map(projectProgress); - case 'progress-write': { - const projected = projectProgress(payload); - if (projected.courseId !== route.courseId) throw new InvalidLearningDataError(); - return projected; - } - case 'generation-start': return projectGeneration(payload); - case 'generation-read': - case 'generation-control': { - const projected = projectGeneration(payload); - if (projected.jobId !== route.jobId) throw new InvalidLearningDataError(); - return projected; - } +function mediaMimeType(response: Response): string | null { + const mimeType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); + return mimeType && TRUSTED_MEDIA_MIME_TYPES.has(mimeType) ? mimeType : null; +} + +async function readBoundedMedia(response: Response): Promise { + const declaredLength = Number(response.headers.get('content-length')); + if (Number.isFinite(declaredLength) && declaredLength > LEARNING_MEDIA_MAX_BYTES) { + await response.body?.cancel().catch(() => undefined); + return null; } + if (!response.body) return null; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > LEARNING_MEDIA_MAX_BYTES) { + await reader.cancel().catch(() => undefined); + return null; + } + chunks.push(value); + } + return size > 0 ? Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))) : null; } function safeError(status: number): { status: number; code: string; error: string } { - if (status === 400 || status === 422) { - return { status, code: 'LEARNING_INVALID_REQUEST', error: '学习请求参数无效' }; - } + if (status === 400 || status === 422) return { status, code: 'LEARNING_INVALID_REQUEST', error: '学习项目请求无效' }; if (status === 401) return { status, code: 'LEARNING_AUTH_REQUIRED', error: '请先登录' }; - if (status === 403) return { status, code: 'LEARNING_FORBIDDEN', error: '没有权限执行此学习操作' }; - if (status === 404) return { status, code: 'LEARNING_NOT_FOUND', error: '课程或任务不存在' }; - if (status === 409) return { status, code: 'LEARNING_CONFLICT', error: '课程或任务状态已变化,请刷新后重试' }; - if (status === 413) return { status, code: 'LEARNING_REQUEST_TOO_LARGE', error: '学习请求内容过大' }; - if (status === 429) return { status, code: 'LEARNING_RATE_LIMITED', error: '请求过于频繁,请稍后再试' }; + if (status === 403) return { status, code: 'LEARNING_FORBIDDEN', error: '没有权限访问学习项目' }; + if (status === 404) return { status, code: 'LEARNING_PROJECT_NOT_FOUND', error: '学习项目不存在或已下架' }; + if (status === 409) return { status, code: 'LEARNING_CONFLICT', error: '学习项目状态已变化,请刷新后重试' }; const safeStatus = status >= 400 && status <= 599 ? status : 502; - return { status: safeStatus, code: 'LEARNING_UNAVAILABLE', error: '学习服务暂时不可用' }; + return { + status: safeStatus, + code: 'LEARNING_UNAVAILABLE', + error: safeStatus === 429 ? '请求过于频繁,请稍后再试' : '学习项目服务暂时不可用', + }; } function sendSafeError(res: ServerResponse, status: number): void { @@ -388,86 +284,156 @@ function sendInvalidResponse(res: ServerResponse): void { success: false, status: 502, code: 'LEARNING_INVALID_RESPONSE', - error: '学习服务返回了无效数据', + error: '学习项目服务返回了无效数据', }); } +async function defaultChooseDestination(ctx: HostApiContext, fileName: string): Promise { + const options: SaveDialogOptions = { + defaultPath: join(app.getPath('downloads'), fileName), + filters: [ + { name: 'ZIP 项目压缩包', extensions: ['zip'] }, + { name: '所有文件', extensions: ['*'] }, + ], + }; + const mainWindow = ctx.mainWindow && !ctx.mainWindow.isDestroyed() ? ctx.mainWindow : null; + const selection = mainWindow + ? await dialog.showSaveDialog(mainWindow, options) + : await dialog.showSaveDialog(options); + return selection.canceled || !selection.filePath ? null : selection.filePath; +} + export function createLearningRouteHandler(dependencies: Dependencies = {}) { const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch; const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken; + const getAccountBinding = dependencies.getAccountBinding ?? getWorksSquareAccountBinding; + const isCurrentAccountBinding = dependencies.isCurrentAccountBinding ?? isCurrentWorksSquareAccountBinding; const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); + const chooseDestination = dependencies.chooseDestination ?? defaultChooseDestination; + const saveArchive = dependencies.saveArchive ?? saveLearningProjectArchive; + + async function authorizedRequest(path: string, accept: string): Promise { + const token = await getAccessToken({ fetchImpl }); + if (!token) throw new LearningProjectDownloadError(401, 'LEARNING_AUTH_REQUIRED', '请先登录'); + const request = (accessToken: string) => fetchImpl(`${apiBaseUrl}${path}`, { + method: 'GET', + headers: { Accept: accept, Authorization: `Bearer ${accessToken}` }, + redirect: 'manual', + }); + let response = await request(token); + if (response.status === 401) { + await response.body?.cancel().catch(() => undefined); + const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true }); + if (!refreshed) throw new LearningProjectDownloadError(401, 'LEARNING_AUTH_REQUIRED', '请先登录'); + response = await request(refreshed); + } + return response; + } + + async function readDetail(route: Extract): Promise { + const response = await authorizedRequest(route.upstreamPath, 'application/json'); + if (!response.ok) { + await response.body?.cancel().catch(() => undefined); + throw new LearningProjectDownloadError(response.status, 'LEARNING_PROJECT_REQUEST_FAILED', '学习项目暂时无法读取'); + } + const detail = projectDetail(unwrapEnvelope(await readBoundedJson(response))); + if (detail.id !== route.projectId) throw new InvalidLearningDataError(); + return detail; + } return async function handleLearningRoutes( req: IncomingMessage, res: ServerResponse, url: URL, - _ctx: HostApiContext, + ctx: HostApiContext, ): Promise { - const route = matchLearningRoute(url.pathname); + const route = matchRoute(url.pathname); if (!route) return false; - const expectedMethod = route.kind === 'progress-write' ? 'PUT' - : route.kind === 'generation-start' || route.kind === 'generation-control' || route.kind === 'generation-finalize' - ? 'POST' - : 'GET'; + const expectedMethod = route.kind === 'download' ? 'POST' : 'GET'; if (req.method !== expectedMethod) { sendJson(res, 405, { success: false, status: 405, code: 'LEARNING_METHOD_NOT_ALLOWED', - error: '不支持的学习请求', + error: '不支持的学习项目请求', }); return true; } try { - let body: Record | undefined; - if (route.kind === 'generation-start') body = projectGenerationOptions(await readBoundedJsonBody(req)); - if (route.kind === 'progress-write') body = projectProgressWrite(await readBoundedJsonBody(req)); - - const token = await getAccessToken({ fetchImpl }); - if (!token) { - sendSafeError(res, 401); - return true; - } - const request = (accessToken: string) => fetchImpl( - `${apiBaseUrl}${route.upstreamPath}${allowedQuery(url, route)}`, - { - method: req.method, - headers: { - Accept: 'application/json', - Authorization: `Bearer ${accessToken}`, - ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - redirect: 'manual', - }, - ); - - let response = await request(token); - if (response.status === 401) { - await response.body?.cancel().catch(() => undefined); - const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true }); - if (!refreshed) { + if (route.kind === 'download') { + const binding = getAccountBinding(); + if (!binding) { sendSafeError(res, 401); return true; } - response = await request(refreshed); + const detail = await readDetail(route); + if (!isCurrentAccountBinding(binding)) throw new LearningProjectDownloadError(409, 'LEARNING_ACCOUNT_CHANGED', '登录账号已更改,请重试'); + const fileName = safeLearningProjectArchiveFileName(detail.archiveFileName, detail.id); + const destinationPath = await chooseDestination(ctx, fileName); + if (!destinationPath) { + sendJson(res, 200, { success: true, data: { status: 'cancelled' } }); + return true; + } + await saveArchive({ + project: detail, + destinationPath, + binding, + fetchImpl, + getAccessToken, + isCurrentAccountBinding, + apiBaseUrl, + }); + sendJson(res, 200, { success: true, data: { status: 'saved' } }); + return true; } + + const response = route.kind === 'list' + ? await authorizedRequest(`${route.upstreamPath}${listQuery(url)}`, 'application/json') + : await authorizedRequest(route.upstreamPath, route.kind === 'media' + ? 'image/avif,image/webp,image/png,image/jpeg,image/gif' + : 'application/json'); if (!response.ok) { await response.body?.cancel().catch(() => undefined); sendSafeError(res, response.status); return true; } - try { - const payload = await readBoundedResponseJson(response); - sendJson(res, response.status, { success: true, data: projectSuccess(route, payload) }); - } catch { - sendInvalidResponse(res); + + if (route.kind === 'media') { + const mimeType = mediaMimeType(response); + if (!mimeType) { + await response.body?.cancel().catch(() => undefined); + sendInvalidResponse(res); + return true; + } + const bytes = await readBoundedMedia(response); + if (!bytes) { + sendInvalidResponse(res); + return true; + } + sendJson(res, 200, { dataBase64: bytes.toString('base64'), mimeType }); + return true; } + + if (route.kind === 'list') { + sendJson(res, 200, { success: true, data: projectPage(await readBoundedJson(response)) }); + return true; + } + + const detail = projectDetail(unwrapEnvelope(await readBoundedJson(response))); + if (detail.id !== route.projectId) throw new InvalidLearningDataError(); + sendJson(res, 200, { success: true, data: detail }); return true; } catch (error) { if (error instanceof InvalidLearningDataError) { - sendSafeError(res, 400); + sendInvalidResponse(res); + } else if (error instanceof LearningProjectDownloadError) { + sendJson(res, error.status, { + success: false, + status: error.status, + code: error.code, + error: error.message, + }); } else { sendSafeError(res, 502); } diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 08f84c0..d9d21c2 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -18,24 +18,6 @@ import { lockAdminSession, verifyAdminPassword, } from './admin-access'; -import { createLearningCourseLibrary, LearningCourseLibraryError } from '../services/learning-course-library'; -import { - assertLearningCoursePackageRegistered, - closeLearningPlayerServer, - evictLearningCoursePackagesForAccount, - getLearningPlayerServer, -} from '../services/learning-player-server'; -import { - getWorksSquareAccountBinding, - isCurrentWorksSquareAccountBinding, - subscribeWorksSquareSession, - type WorksSquareAccountBinding, -} from '../services/works-square-session'; -import { createLearningAgentClient, type LearningAgentRequest } from '../services/learning-agent-client'; -import { createLearningSpeechClient, type LearningSpeechRequest } from '../services/learning-speech-client'; -import { createLearningGenerationClient } from '../services/learning-generation-client'; -import { createLearningRuntimeBridge } from '../services/learning-runtime-bridge'; -import type { LearningGenerationUploadRequest, LearningRuntimeBridgeRequest } from '../../shared/learning'; import type { BackgroundLifecycleController, DesktopActivity } from './background-lifecycle'; import { collectPerformanceSnapshot } from './performance-diagnostics'; import type { HostApiContext } from '../api/context'; @@ -58,10 +40,6 @@ type UnifiedResponse = { }; }; -const LEARNING_COURSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; -const LEARNING_MODULE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/; -const LEARNING_SHA256_PATTERN = /^[0-9a-f]{64}$/; - function ok(id: string | undefined, data: unknown): UnifiedResponse { return { id, ok: true, data }; } @@ -78,114 +56,6 @@ function unsupported(id: string | undefined, module: string, action: string): Un return fail(id, 'UNSUPPORTED', `APP_REQUEST_UNSUPPORTED:${module}:${action}`); } -async function withLearningBinding( - operation: ( - binding: WorksSquareAccountBinding, - assertCurrentAccount: () => void, - ) => T | Promise, -): Promise { - const binding = getWorksSquareAccountBinding(); - if (!binding) { - throw new LearningCourseLibraryError('LEARNING_AUTH_REQUIRED', '请先登录'); - } - const assertCurrentAccount = () => { - if (!isCurrentWorksSquareAccountBinding(binding)) { - throw new LearningCourseLibraryError('LEARNING_ACCOUNT_CHANGED', '登录账号已更改,请重试'); - } - }; - assertCurrentAccount(); - const result = await operation(binding, assertCurrentAccount); - assertCurrentAccount(); - return result; -} - -function assertLearningCoursePackageActive( - binding: WorksSquareAccountBinding, - courseId: string, - contentHash: string, -): void { - try { - assertLearningCoursePackageRegistered(binding.accountKey, courseId, contentHash); - } catch { - throw new LearningCourseLibraryError('LEARNING_COURSE_IDENTITY_INVALID', '课程身份无效'); - } -} - -function assertLearningCourseIdentity( - requested: { courseId: string; contentHash: string; moduleId?: string | null; moduleContentHash?: string }, - authoritative: { courseId: string; courseContentHash: string; moduleId: string | null; moduleContentHash: string }, -): void { - if (requested.courseId !== authoritative.courseId - || requested.contentHash !== authoritative.courseContentHash - || (requested.moduleId !== undefined && requested.moduleId !== authoritative.moduleId) - || (requested.moduleContentHash !== undefined - && requested.moduleContentHash !== authoritative.moduleContentHash)) { - throw new LearningCourseLibraryError('LEARNING_COURSE_IDENTITY_INVALID', '课程身份无效'); - } -} - -function learningIdentityError(): LearningCourseLibraryError { - return new LearningCourseLibraryError('LEARNING_COURSE_IDENTITY_INVALID', '课程身份无效'); -} - -function requireLearningAgentIdentity(request: unknown): { - courseId: string; - contentHash: string; - moduleId?: string | null; - moduleContentHash?: string; -} { - if (!request || typeof request !== 'object' || Array.isArray(request)) throw learningIdentityError(); - const input = request as Record; - if (typeof input.courseId !== 'string' || !LEARNING_COURSE_ID_PATTERN.test(input.courseId) - || typeof input.contentHash !== 'string' || !LEARNING_SHA256_PATTERN.test(input.contentHash)) { - throw learningIdentityError(); - } - if (input.anchor === undefined) return { courseId: input.courseId, contentHash: input.contentHash }; - if (!input.anchor || typeof input.anchor !== 'object' || Array.isArray(input.anchor)) throw learningIdentityError(); - const anchor = input.anchor as Record; - if (anchor.moduleId !== undefined && anchor.moduleId !== null - && (typeof anchor.moduleId !== 'string' || !LEARNING_MODULE_ID_PATTERN.test(anchor.moduleId))) { - throw learningIdentityError(); - } - if (anchor.moduleContentHash !== undefined - && (typeof anchor.moduleContentHash !== 'string' - || !LEARNING_SHA256_PATTERN.test(anchor.moduleContentHash))) { - throw learningIdentityError(); - } - return { - courseId: input.courseId, - contentHash: input.contentHash, - ...(anchor.moduleId === undefined ? {} : { moduleId: anchor.moduleId as string | null }), - ...(anchor.moduleContentHash === undefined ? {} : { moduleContentHash: anchor.moduleContentHash as string }), - }; -} - -function requireLearningRuntimeIdentity(request: unknown): { - courseId: string; - contentHash: string; - moduleId: string | null; - moduleContentHash: string; -} { - if (!request || typeof request !== 'object' || Array.isArray(request)) throw learningIdentityError(); - const input = request as Record; - const context = input.context; - if (typeof input.courseId !== 'string' || !LEARNING_COURSE_ID_PATTERN.test(input.courseId) - || typeof input.contentHash !== 'string' || !LEARNING_SHA256_PATTERN.test(input.contentHash) - || !context || typeof context !== 'object' || Array.isArray(context)) throw learningIdentityError(); - const runtimeContext = context as Record; - if ((runtimeContext.moduleId !== null - && (typeof runtimeContext.moduleId !== 'string' - || !LEARNING_MODULE_ID_PATTERN.test(runtimeContext.moduleId))) - || typeof runtimeContext.moduleContentHash !== 'string' - || !LEARNING_SHA256_PATTERN.test(runtimeContext.moduleContentHash)) throw learningIdentityError(); - return { - courseId: input.courseId, - contentHash: input.contentHash, - moduleId: runtimeContext.moduleId as string | null, - moduleContentHash: runtimeContext.moduleContentHash, - }; -} - function payloadArray(payload: unknown): unknown[] { return Array.isArray(payload) ? payload : payload === undefined ? [] : [payload]; } @@ -391,127 +261,6 @@ export function registerIpcHandlers( rendererWebContents.on('render-process-gone', releaseRendererLeases); rendererWebContents.on('destroyed', releaseRendererLeases); } - const learningCourseLibrary = createLearningCourseLibrary({ rootDirectory: app.getPath('userData') }); - let learningAgentClient = createLearningAgentClient(); - const learningSpeechClient = createLearningSpeechClient(); - const learningGenerationClient = createLearningGenerationClient(); - const learningRuntimeBridge = createLearningRuntimeBridge(); - let learningPlayerClose = Promise.resolve(); - let activeLearningBinding = getWorksSquareAccountBinding(); - subscribeWorksSquareSession(() => { - const nextBinding = getWorksSquareAccountBinding(); - const bindingChanged = activeLearningBinding?.accountKey !== nextBinding?.accountKey - || activeLearningBinding?.epoch !== nextBinding?.epoch; - if (bindingChanged) { - if (activeLearningBinding) { - evictLearningCoursePackagesForAccount(activeLearningBinding.accountKey); - } - learningPlayerClose = closeLearningPlayerServer().catch(() => undefined); - learningAgentClient = createLearningAgentClient(); - } - activeLearningBinding = nextBinding; - }); - - ipcMain.handle('learning:startGeneration', (_event, request: LearningGenerationUploadRequest) => ( - withLearningBinding((_binding, assertCurrentAccount) => ( - learningGenerationClient.start(request, assertCurrentAccount) - )) - )); - ipcMain.handle('learning:download', (_event, courseId: string) => ( - withLearningBinding(() => learningCourseLibrary.download(courseId)) - )); - ipcMain.handle('learning:listInstalled', () => ( - withLearningBinding(() => learningCourseLibrary.listInstalled()) - )); - ipcMain.handle('learning:readClassroom', (_event, courseId: string, moduleId?: string) => ( - withLearningBinding(() => learningCourseLibrary.readClassroom(courseId, moduleId)) - )); - ipcMain.handle('learning:playerUrl', () => withLearningBinding(async (binding, assertCurrentAccount) => { - await learningPlayerClose; - assertCurrentAccount(); - return (await getLearningPlayerServer(binding.accountKey)).url; - })); - ipcMain.handle('learning:closePlayer', () => closeLearningPlayerServer()); - ipcMain.handle('learning:agentAsk', (_event, request: unknown) => withLearningBinding( - async (binding, assertCurrentAccount) => { - const identity = requireLearningAgentIdentity(request); - const agentRequest = request as LearningAgentRequest; - assertLearningCoursePackageActive(binding, identity.courseId, identity.contentHash); - const classroom = await learningCourseLibrary.resolveClassroom(identity.courseId, identity.moduleId ?? undefined); - assertCurrentAccount(); - assertLearningCourseIdentity({ - courseId: identity.courseId, - contentHash: identity.contentHash, - moduleId: identity.moduleId, - moduleContentHash: identity.moduleContentHash, - }, classroom); - return learningAgentClient.ask({ - ...agentRequest, - contentHash: classroom.courseContentHash, - anchor: { - ...agentRequest.anchor, - moduleId: classroom.moduleId, - moduleContentHash: classroom.moduleContentHash, - }, - }, assertCurrentAccount); - }, - )); - ipcMain.handle('learning:agentReset', (_event, courseId?: string, contentHash?: string) => withLearningBinding( - async (binding, assertCurrentAccount) => { - if (courseId === undefined && contentHash === undefined) { - return learningAgentClient.reset(undefined, undefined, assertCurrentAccount); - } - if (typeof courseId !== 'string' || !LEARNING_COURSE_ID_PATTERN.test(courseId) - || typeof contentHash !== 'string' || !LEARNING_SHA256_PATTERN.test(contentHash)) { - throw learningIdentityError(); - } - assertLearningCoursePackageActive(binding, courseId, contentHash); - const classroom = await learningCourseLibrary.resolveClassroom(courseId); - assertCurrentAccount(); - assertLearningCourseIdentity({ - courseId, - contentHash, - }, classroom); - return learningAgentClient.reset(courseId, classroom.courseContentHash, assertCurrentAccount); - }, - )); - ipcMain.handle('learning:transcribe', (_event, request: LearningSpeechRequest) => ( - withLearningBinding((_binding, assertCurrentAccount) => ( - learningSpeechClient.transcribe(request, assertCurrentAccount) - )) - )); - ipcMain.handle('learning:runtimeRequest', (event, request: unknown) => withLearningBinding( - async (binding, assertCurrentAccount) => { - const identity = requireLearningRuntimeIdentity(request); - const runtimeRequest = request as LearningRuntimeBridgeRequest; - assertLearningCoursePackageActive(binding, identity.courseId, identity.contentHash); - const classroom = await learningCourseLibrary.resolveClassroom( - identity.courseId, - identity.moduleId ?? undefined, - ); - assertCurrentAccount(); - assertLearningCourseIdentity({ - courseId: identity.courseId, - contentHash: identity.contentHash, - moduleId: identity.moduleId, - moduleContentHash: identity.moduleContentHash, - }, classroom); - await learningRuntimeBridge.request({ - ...runtimeRequest, - contentHash: classroom.courseContentHash, - context: { - ...runtimeRequest.context, - moduleId: classroom.moduleId, - moduleContentHash: classroom.moduleContentHash, - }, - }, (payload) => { - if (isCurrentWorksSquareAccountBinding(binding) && !event.sender.isDestroyed()) { - event.sender.send('learning:runtime-event', payload); - } - }, assertCurrentAccount); - }, - )); - ipcMain.handle('app:request', async (_event, request: UnifiedRequest) => ( handleUnifiedRequest(request, opencodeManager) )); diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 2ab9f3f..fd44722 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -20,7 +20,6 @@ const validInvokeChannels = [ 'opencode:health', 'lifecycle:activity', 'lifecycle:lease', - 'learning:closePlayer', 'shell:openExternal', 'shell:showItemInFolder', 'shell:openPath', @@ -48,15 +47,6 @@ const validInvokeChannels = [ 'admin:verifyPassword', 'admin:isUnlocked', 'admin:lock', - 'learning:startGeneration', - 'learning:download', - 'learning:listInstalled', - 'learning:readClassroom', - 'learning:playerUrl', - 'learning:agentAsk', - 'learning:agentReset', - 'learning:transcribe', - 'learning:runtimeRequest', 'provider:list', 'provider:get', 'provider:save', @@ -100,7 +90,6 @@ const validEventChannels = [ 'lifecycle:pause', 'lifecycle:sleep', 'release-job:status', - 'learning:runtime-event', ]; /** diff --git a/electron/services/learning-agent-client.ts b/electron/services/learning-agent-client.ts deleted file mode 100644 index 69acfa4..0000000 --- a/electron/services/learning-agent-client.ts +++ /dev/null @@ -1,392 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { WORKS_SQUARE_CONFIG } from '../api/works-config'; -import { proxyAwareFetch } from '../utils/proxy-fetch'; -import { getValidWorksSquareAccessToken } from './works-square-session'; - -const COURSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; -const MODULE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/; -const SHA256_PATTERN = /^[0-9a-f]{64}$/; -const REMOTE_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/; -const MAX_MESSAGE_LENGTH = 4_000; -const MAX_HISTORY_ITEMS = 8; -const MAX_HISTORY_CONTENT_LENGTH = 4_000; -const MAX_SCENE_TEXT_LENGTH = 256; -const MAX_ANSWER_LENGTH = 64_000; -const MAX_SSE_BUFFER_LENGTH = 256_000; - -export type LearningAgentRequest = { - courseId: string; - contentHash: string; - message: string; - history?: Array<{ role: 'user' | 'assistant'; content: string }>; - anchor?: { - sceneId?: string; - sceneOrder?: number; - sceneTitle?: string; - actionIndex?: number; - moduleId?: string | null; - moduleContentHash?: string; - }; -}; - -type Dependencies = { - fetchImpl?: typeof fetch; - getAccessToken?: typeof getValidWorksSquareAccessToken; - apiBaseUrl?: string; -}; - -class LearningAgentHttpError extends Error { - constructor(readonly status: number, message: string) { - super(message); - this.name = 'LearningAgentHttpError'; - } -} - -function record(value: unknown): Record { - return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; -} - -function isLearningAccountChanged(error: unknown): boolean { - return record(error).code === 'LEARNING_ACCOUNT_CHANGED'; -} - -function createSseDataParser() { - let line = ''; - let pendingCarriageReturn = false; - let dataLines: string[] = []; - - const assertBufferSize = () => { - const size = line.length + dataLines.reduce((total, data) => total + data.length, 0); - if (size > MAX_SSE_BUFFER_LENGTH) throw new Error('助教服务暂时不可用'); - }; - - const commitLine = (events: string[]) => { - if (line === '') { - if (dataLines.length > 0) events.push(dataLines.join('\n')); - dataLines = []; - return; - } - if (!line.startsWith(':')) { - const separator = line.indexOf(':'); - const field = separator === -1 ? line : line.slice(0, separator); - let value = separator === -1 ? '' : line.slice(separator + 1); - if (value.startsWith(' ')) value = value.slice(1); - if (field === 'data') { - dataLines.push(value); - assertBufferSize(); - } - } - line = ''; - }; - - const push = (chunk: string): string[] => { - const events: string[] = []; - for (const character of chunk) { - if (pendingCarriageReturn) { - pendingCarriageReturn = false; - commitLine(events); - if (character === '\n') continue; - } - if (character === '\r') { - pendingCarriageReturn = true; - } else if (character === '\n') { - commitLine(events); - } else { - line += character; - assertBufferSize(); - } - } - return events; - }; - - const finish = (): string[] => { - const events: string[] = []; - if (pendingCarriageReturn) { - pendingCarriageReturn = false; - commitLine(events); - } - if (line !== '') commitLine(events); - if (dataLines.length > 0) { - events.push(dataLines.join('\n')); - dataLines = []; - } - return events; - }; - - return { push, finish }; -} - -export function createLearningAgentClient(dependencies: Dependencies = {}) { - const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch; - const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken; - const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); - const sessions = new Map(); - const sessionFlights = new Map>(); - const turnTails = new Map>(); - - async function authorizedJson( - path: string, - init: RequestInit, - assertCurrentAccount: () => void, - ): Promise> { - assertCurrentAccount(); - try { - const token = await getAccessToken({ fetchImpl }); - assertCurrentAccount(); - if (!token) throw new Error('请先登录'); - const request = (accessToken: string) => { - assertCurrentAccount(); - return fetchImpl(`${apiBaseUrl}${path}`, { - ...init, - headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` }, - }); - }; - let response = await request(token); - assertCurrentAccount(); - if (response.status === 401) { - await response.body?.cancel().catch(() => undefined); - assertCurrentAccount(); - const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true }); - assertCurrentAccount(); - if (refreshed) { - response = await request(refreshed); - assertCurrentAccount(); - } - } - const payload = await response.json().catch(() => null); - if (!response.ok) throw new LearningAgentHttpError(response.status, '助教服务暂时不可用'); - return record(payload); - } catch (error) { - if (isLearningAccountChanged(error)) throw error; - if (error instanceof LearningAgentHttpError || (error instanceof Error && error.message === '请先登录')) throw error; - throw new Error('助教服务暂时不可用', { cause: error }); - } - } - - async function createSession( - bindingKey: string, - courseId: string, - assertCurrentAccount: () => void, - ): Promise { - assertCurrentAccount(); - const existing = sessions.get(bindingKey); - if (existing) return existing; - const inFlight = sessionFlights.get(bindingKey); - if (inFlight) return inFlight; - const pending = authorizedJson('/api/agents/sessions', { - method: 'POST', - body: JSON.stringify({ - client_session_id: `learning-${courseId}-${randomUUID()}`, - runtime: 'learning', - runtime_version: 'v1', - binding: { kind: 'learning-course', key: bindingKey }, - }), - }, assertCurrentAccount).then((session) => { - assertCurrentAccount(); - const sessionId = typeof session.session_id === 'string' && REMOTE_ID_PATTERN.test(session.session_id) - ? session.session_id - : ''; - if (!sessionId) throw new Error('助教服务暂时不可用'); - sessions.set(bindingKey, sessionId); - return sessionId; - }).finally(() => sessionFlights.delete(bindingKey)); - sessionFlights.set(bindingKey, pending); - return pending; - } - - function safeTurnInput(input: LearningAgentRequest) { - if (!input || typeof input.message !== 'string') throw new Error('助教请求无效'); - const message = input.message.trim(); - const history = Array.isArray(input.history) ? input.history.slice(-MAX_HISTORY_ITEMS).map((item) => { - if ((item?.role !== 'user' && item?.role !== 'assistant') - || typeof item.content !== 'string' - || !item.content.trim() - || item.content.length > MAX_HISTORY_CONTENT_LENGTH) throw new Error('助教请求无效'); - return { role: item.role, content: item.content }; - }) : []; - const anchor = input.anchor ?? {}; - if ((anchor.sceneId !== undefined && (typeof anchor.sceneId !== 'string' || !anchor.sceneId || anchor.sceneId.length > MAX_SCENE_TEXT_LENGTH)) - || (anchor.sceneTitle !== undefined && (typeof anchor.sceneTitle !== 'string' || anchor.sceneTitle.length > MAX_SCENE_TEXT_LENGTH)) - || (anchor.sceneOrder !== undefined && (!Number.isSafeInteger(anchor.sceneOrder) || anchor.sceneOrder < 0 || anchor.sceneOrder > 100_000)) - || (anchor.actionIndex !== undefined && (!Number.isSafeInteger(anchor.actionIndex) || anchor.actionIndex < 0 || anchor.actionIndex > 100_000)) - || (anchor.moduleId !== undefined && anchor.moduleId !== null && !MODULE_ID_PATTERN.test(anchor.moduleId)) - || (anchor.moduleContentHash !== undefined && !SHA256_PATTERN.test(anchor.moduleContentHash))) { - throw new Error('助教请求无效'); - } - return { - message, - history, - anchor: { - ...(anchor.sceneId === undefined ? {} : { sceneId: anchor.sceneId }), - ...(anchor.sceneOrder === undefined ? {} : { sceneOrder: anchor.sceneOrder }), - ...(anchor.sceneTitle === undefined ? {} : { sceneTitle: anchor.sceneTitle }), - ...(anchor.actionIndex === undefined ? {} : { actionIndex: anchor.actionIndex }), - ...(anchor.moduleId === undefined ? {} : { moduleId: anchor.moduleId }), - ...(anchor.moduleContentHash === undefined ? {} : { moduleContentHash: anchor.moduleContentHash }), - }, - }; - } - - async function runTurn( - sessionId: string, - input: ReturnType, - assertCurrentAccount: () => void, - ): Promise<{ text: string }> { - assertCurrentAccount(); - const command = await authorizedJson(`/api/agents/sessions/${encodeURIComponent(sessionId)}/commands`, { - method: 'POST', - body: JSON.stringify({ - client_command_id: `turn-${randomUUID()}`, - name: 'turn.submit', - input: { - message: input.message, - // Compatibility context for an older runtime. The durable Works - // Agent session remains authoritative across turns. - history: input.history, - anchor: input.anchor, - }, - }), - }, assertCurrentAccount); - const runId = typeof command.run_id === 'string' && REMOTE_ID_PATTERN.test(command.run_id) ? command.run_id : ''; - const ticket = await authorizedJson(`/api/agents/sessions/${encodeURIComponent(sessionId)}/stream-tickets`, { - method: 'POST', - body: JSON.stringify({ transport: 'sse' }), - }, assertCurrentAccount); - const streamUrl = typeof ticket.stream_url === 'string' && ticket.stream_url.length <= 2_048 - ? ticket.stream_url - : ''; - const expectedStreamPath = `/api/agents/sessions/${encodeURIComponent(sessionId)}/events`; - let streamTarget: URL | null = null; - try { - const candidate = new URL(streamUrl, `${apiBaseUrl}/`); - const base = new URL(apiBaseUrl); - if (candidate.origin === base.origin && candidate.pathname === expectedStreamPath) streamTarget = candidate; - } catch { - streamTarget = null; - } - if (!runId || !streamTarget) throw new Error('助教服务暂时不可用'); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 120_000); - let response: Response; - try { - assertCurrentAccount(); - response = await fetchImpl(streamTarget, { headers: { Accept: 'text/event-stream' }, signal: controller.signal }); - assertCurrentAccount(); - } catch (error) { - clearTimeout(timeout); - if (isLearningAccountChanged(error)) throw error; - throw new Error('助教服务暂时不可用', { cause: error }); - } - if (!response.ok || !response.body) { - clearTimeout(timeout); - throw new LearningAgentHttpError(response.status, '助教事件通道不可用'); - } - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - const parser = createSseDataParser(); - let text = ''; - const consume = (data: string): { text?: string; completed?: true } => { - if (!data) return {}; - let envelope: Record; - try { - envelope = record(JSON.parse(data)); - } catch { - throw new Error('助教服务暂时不可用'); - } - if (envelope.run_id !== runId) return {}; - const payload = record(envelope.payload); - if (envelope.type === 'learning.assistant.delta' && typeof payload.delta === 'string') { - if (text.length + payload.delta.length > MAX_ANSWER_LENGTH) throw new Error('助教服务暂时不可用'); - text += payload.delta; - } - if (envelope.type === 'learning.assistant.failed') { - throw new Error('助教回答失败'); - } - if (envelope.type === 'learning.assistant.completed') { - const answer = text.trim(); - if (!answer) throw new Error('助教回答失败'); - return { text: answer, completed: true }; - } - return {}; - }; - try { - while (true) { - const chunk = await reader.read(); - assertCurrentAccount(); - const events = chunk.done - ? [...parser.push(decoder.decode()), ...parser.finish()] - : parser.push(decoder.decode(chunk.value, { stream: true })); - for (const data of events) { - const result = consume(data); - if (result.completed) { - controller.abort(); - return { text: result.text ?? '' }; - } - } - if (chunk.done) break; - } - } finally { - clearTimeout(timeout); - await reader.cancel().catch(() => undefined); - } - throw new Error('助教回答意外中断'); - } - - async function ask( - input: LearningAgentRequest, - assertCurrentAccount: () => void = () => undefined, - ): Promise<{ text: string }> { - assertCurrentAccount(); - const safeInput = safeTurnInput(input); - if (!COURSE_ID_PATTERN.test(input.courseId) - || !SHA256_PATTERN.test(input.contentHash) - || !safeInput.message - || safeInput.message.length > MAX_MESSAGE_LENGTH) throw new Error('助教请求无效'); - const bindingKey = `${input.courseId}:${input.contentHash}`; - const previous = turnTails.get(bindingKey) ?? Promise.resolve(); - const turn = previous.catch(() => undefined).then(async () => { - assertCurrentAccount(); - let sessionId = await createSession(bindingKey, input.courseId, assertCurrentAccount); - try { - return await runTurn(sessionId, safeInput, assertCurrentAccount); - } catch (error) { - if (!(error instanceof LearningAgentHttpError) || error.status !== 404) throw error; - assertCurrentAccount(); - sessions.delete(bindingKey); - sessionId = await createSession(bindingKey, input.courseId, assertCurrentAccount); - assertCurrentAccount(); - return runTurn(sessionId, safeInput, assertCurrentAccount); - } - }); - const tail = turn.then(() => undefined, () => undefined); - turnTails.set(bindingKey, tail); - try { - return await turn; - } finally { - if (turnTails.get(bindingKey) === tail) turnTails.delete(bindingKey); - } - } - - async function reset( - courseId?: string, - contentHash?: string, - assertCurrentAccount: () => void = () => undefined, - ): Promise { - assertCurrentAccount(); - const exactKey = courseId && contentHash ? `${courseId}:${contentHash}` : null; - const entries = [...sessions.entries()].filter(([key]) => exactKey - ? key === exactKey - : courseId ? key.startsWith(`${courseId}:`) : true); - for (const [key, sessionId] of entries) { - assertCurrentAccount(); - sessions.delete(key); - void authorizedJson( - `/api/agents/sessions/${encodeURIComponent(sessionId)}`, - { method: 'DELETE' }, - assertCurrentAccount, - ).catch(() => undefined); - } - } - - return { ask, reset }; -} diff --git a/electron/services/learning-course-library.ts b/electron/services/learning-course-library.ts deleted file mode 100644 index 4e685ad..0000000 --- a/electron/services/learning-course-library.ts +++ /dev/null @@ -1,525 +0,0 @@ -import { createHash, randomUUID } from 'node:crypto'; -import { createReadStream } from 'node:fs'; -import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; -import AdmZip from 'adm-zip'; -import { - LEARNING_ARCHIVE_MAX_BYTES, - type InstalledLearningCourse, - type LearningClassroomPayload, - type LearningCourse, -} from '../../shared/learning'; -import { WORKS_SQUARE_CONFIG } from '../api/works-config'; -import { proxyAwareFetch } from '../utils/proxy-fetch'; -import { - getValidWorksSquareAccessToken, - getWorksSquareAccountBinding, - isCurrentWorksSquareAccountBinding, - type WorksSquareAccountBinding, -} from './works-square-session'; -import { readLearningPackageClassroom } from './learning-package-consumer'; -import { registerLearningCoursePackage } from './learning-player-server'; - -const COURSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; -const MODULE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/; -const SHA256_PATTERN = /^[0-9a-f]{64}$/; -const ACCOUNT_KEY_PATTERN = /^[0-9a-f]{64}$/; -export const LEARNING_DOWNLOAD_MAX_REDIRECTS = 5; - -export class LearningCourseLibraryError extends Error { - constructor(readonly code: string, message: string) { - super(message); - this.name = 'LearningCourseLibraryError'; - } -} - -type Dependencies = { - rootDirectory: string; - fetchImpl?: typeof fetch; - getAccessToken?: typeof getValidWorksSquareAccessToken; - apiBaseUrl?: string; - now?: () => Date; - getAccountBinding?: () => WorksSquareAccountBinding | null; - isCurrentAccountBinding?: (binding: WorksSquareAccountBinding) => boolean; -}; - -type InstalledLearningCourseDescriptor = InstalledLearningCourse & { - archivePath: string; -}; - -function accountCoursesDirectory(root: string, accountKey: string): string { - return join(root, 'learning', 'accounts', accountKey, 'courses'); -} - -function courseDirectory(root: string, accountKey: string, course: LearningCourse): string { - return join(accountCoursesDirectory(root, accountKey), course.id, course.contentHash); -} - -function assertCourseId(courseId: string): void { - if (!COURSE_ID_PATTERN.test(courseId)) { - throw new LearningCourseLibraryError('LEARNING_COURSE_ID_INVALID', '课程编号无效'); - } -} - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function parseCourse(payload: unknown): LearningCourse { - if (isRecord(payload) - && typeof payload.archiveBytes === 'number' - && Number.isSafeInteger(payload.archiveBytes) - && payload.archiveBytes > LEARNING_ARCHIVE_MAX_BYTES) { - throw new LearningCourseLibraryError('LEARNING_ARCHIVE_TOO_LARGE', '课程包超过本机允许的大小'); - } - const capabilities = isRecord(payload) && isRecord(payload.capabilities) ? payload.capabilities : null; - const hasSingleCapabilities = Boolean(capabilities - && (capabilities.modular === undefined || capabilities.modular === false) - && Array.isArray(capabilities.sceneKinds) - && capabilities.sceneKinds.every((kind) => ['slide', 'interactive', 'quiz', 'pbl'].includes(String(kind))) - && typeof capabilities.hasAudio === 'boolean' - && typeof capabilities.hasWhiteboard === 'boolean' - && typeof capabilities.hasAgent === 'boolean'); - const hasLargeCapabilities = Boolean(capabilities - && capabilities.modular === true - && capabilities.orderedModules === true - && capabilities.productionStagePerModule === true); - const modules = isRecord(payload) && Array.isArray(payload.modules) ? payload.modules : null; - const moduleIds = modules?.flatMap((module) => isRecord(module) && typeof module.moduleId === 'string' - ? [module.moduleId] - : []) ?? []; - if (!isRecord(payload) - || typeof payload.id !== 'string' - || !COURSE_ID_PATTERN.test(payload.id) - || !['user_single', 'ops_large'].includes(String(payload.origin)) - || !['generating', 'ready', 'published', 'failed', 'archived'].includes(String(payload.status)) - || typeof payload.title !== 'string' - || !payload.title.trim() - || (payload.summary !== null && typeof payload.summary !== 'string') - || (payload.language !== null && typeof payload.language !== 'string') - || typeof payload.contentHash !== 'string' - || !SHA256_PATTERN.test(payload.contentHash) - || typeof payload.archiveSha256 !== 'string' - || !SHA256_PATTERN.test(payload.archiveSha256) - || typeof payload.archiveBytes !== 'number' - || !Number.isSafeInteger(payload.archiveBytes) - || payload.archiveBytes <= 0 - || !Number.isSafeInteger(payload.formatVersion) - || Number(payload.formatVersion) < 1 - || typeof payload.minPlayerVersion !== 'string' - || !payload.minPlayerVersion.trim() - || !Number.isSafeInteger(payload.sceneCount) - || Number(payload.sceneCount) < 0 - || (!hasSingleCapabilities && !hasLargeCapabilities) - || typeof payload.createdAt !== 'string' - || (payload.publishedAt !== null && typeof payload.publishedAt !== 'string') - || (modules && new Set(moduleIds).size !== modules.length) - || (payload.modules !== undefined && (!modules || modules.some((module) => ( - !isRecord(module) - || typeof module.moduleId !== 'string' - || !MODULE_ID_PATTERN.test(module.moduleId) - || typeof module.title !== 'string' - || (module.summary !== null && typeof module.summary !== 'string') - || typeof module.sceneCount !== 'number' - || !Number.isSafeInteger(module.sceneCount) - || module.sceneCount < 0 - || typeof module.contentHash !== 'string' - || !SHA256_PATTERN.test(module.contentHash) - ))))) { - throw new LearningCourseLibraryError('LEARNING_COURSE_INVALID', '课程信息不完整'); - } - return payload as LearningCourse; -} - -function parseInstalled(payload: unknown): InstalledLearningCourseDescriptor | null { - if (!isRecord(payload) - || payload.schemaVersion !== 1 - || typeof payload.archivePath !== 'string' - || typeof payload.installedAt !== 'string') return null; - try { - return { ...payload, course: parseCourse(payload.course) } as InstalledLearningCourseDescriptor; - } catch { - return null; - } -} - -async function safeJson(response: Response): Promise { - return response.json().catch(() => null); -} - -function upstreamError(payload: unknown, status: number): LearningCourseLibraryError { - void payload; - void status; - return new LearningCourseLibraryError( - 'LEARNING_SERVICE_UNAVAILABLE', - '学习服务暂时不可用', - ); -} - -async function streamVerifiedArchive( - response: Response, - temporaryPath: string, - course: LearningCourse, - assertCurrentAccount: () => void, -): Promise { - if (!response.body) { - throw new LearningCourseLibraryError('LEARNING_DOWNLOAD_EMPTY', '课程包内容为空'); - } - const handle = await open(temporaryPath, 'wx'); - const hash = createHash('sha256'); - let bytes = 0; - const signature: number[] = []; - const reader = response.body.getReader(); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - assertCurrentAccount(); - bytes += value.byteLength; - if (bytes > course.archiveBytes) { - throw new LearningCourseLibraryError('LEARNING_ARCHIVE_SIZE_MISMATCH', '课程包大小校验失败'); - } - for (const byte of value.subarray(0, Math.max(0, 4 - signature.length))) signature.push(byte); - hash.update(value); - await handle.write(value); - } - } finally { - reader.releaseLock(); - await handle.close(); - } - if (bytes !== course.archiveBytes) { - throw new LearningCourseLibraryError('LEARNING_ARCHIVE_SIZE_MISMATCH', '课程包大小校验失败'); - } - if (signature.length < 4 || signature[0] !== 0x50 || signature[1] !== 0x4b - || !((signature[2] === 0x03 && signature[3] === 0x04) - || (signature[2] === 0x05 && signature[3] === 0x06) - || (signature[2] === 0x07 && signature[3] === 0x08))) { - throw new LearningCourseLibraryError('LEARNING_ARCHIVE_INVALID', '课程包不是有效的 ZIP 文件'); - } - if (hash.digest('hex') !== course.archiveSha256) { - throw new LearningCourseLibraryError('LEARNING_ARCHIVE_HASH_MISMATCH', '课程包完整性校验失败'); - } -} - -async function fileSha256(path: string): Promise { - const hash = createHash('sha256'); - for await (const chunk of createReadStream(path)) hash.update(chunk); - return hash.digest('hex'); -} - -export function createLearningCourseLibrary(dependencies: Dependencies) { - const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch; - const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken; - const getAccountBinding = dependencies.getAccountBinding ?? getWorksSquareAccountBinding; - const isCurrentAccountBinding = dependencies.isCurrentAccountBinding - ?? isCurrentWorksSquareAccountBinding; - const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); - const apiOrigin = new URL(apiBaseUrl).origin; - const now = dependencies.now ?? (() => new Date()); - const activeDownloads = new Map>(); - - function requireAccountBinding(): WorksSquareAccountBinding { - const binding = getAccountBinding(); - if (!binding || !ACCOUNT_KEY_PATTERN.test(binding.accountKey)) { - throw new LearningCourseLibraryError('LEARNING_AUTH_REQUIRED', '请先登录'); - } - return binding; - } - - function assertCurrentAccount(binding: WorksSquareAccountBinding): void { - if (!isCurrentAccountBinding(binding)) { - throw new LearningCourseLibraryError('LEARNING_ACCOUNT_CHANGED', '登录账号已更改,请重试'); - } - } - - function toPublicRecord(record: InstalledLearningCourseDescriptor): InstalledLearningCourse { - return { - schemaVersion: record.schemaVersion, - course: record.course, - installedAt: record.installedAt, - }; - } - - async function withFixedErrors(operation: () => Promise): Promise { - try { - return await operation(); - } catch (error) { - if (error instanceof LearningCourseLibraryError) throw error; - throw new LearningCourseLibraryError( - 'LEARNING_LOCAL_LIBRARY_FAILED', - '本地课程库操作失败,请重试', - ); - } - } - - async function authorizedFetch( - path: string, - binding: WorksSquareAccountBinding, - ): Promise { - assertCurrentAccount(binding); - const token = await getAccessToken({ fetchImpl }); - assertCurrentAccount(binding); - if (!token) throw new LearningCourseLibraryError('LEARNING_AUTH_REQUIRED', '请先登录'); - const request = (accessToken: string) => fetchImpl(`${apiBaseUrl}${path}`, { - headers: { Accept: path.endsWith('/download') ? 'application/zip' : 'application/json', Authorization: `Bearer ${accessToken}` }, - redirect: 'manual', - }); - let response = await request(token); - assertCurrentAccount(binding); - if (response.status === 401) { - await response.body?.cancel().catch(() => undefined); - assertCurrentAccount(binding); - const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true }); - assertCurrentAccount(binding); - if (refreshed) { - response = await request(refreshed); - assertCurrentAccount(binding); - } - } - return response; - } - - async function followDownloadRedirect( - initialResponse: Response, - initialUrl: string, - binding: WorksSquareAccountBinding, - ): Promise { - let response = initialResponse; - let currentUrl = new URL(initialUrl).href; - const visited = new Set([currentUrl]); - for (let hop = 0; [301, 302, 303, 307, 308].includes(response.status); hop += 1) { - if (hop >= LEARNING_DOWNLOAD_MAX_REDIRECTS) { - await response.body?.cancel().catch(() => undefined); - throw new LearningCourseLibraryError('LEARNING_DOWNLOAD_REDIRECT_LIMIT', '课程下载重定向次数过多'); - } - const location = response.headers.get('location'); - await response.body?.cancel().catch(() => undefined); - if (!location) { - throw new LearningCourseLibraryError('LEARNING_DOWNLOAD_REDIRECT_INVALID', '课程下载地址无效'); - } - let target: URL; - try { - target = new URL(location, currentUrl); - } catch { - throw new LearningCourseLibraryError('LEARNING_DOWNLOAD_REDIRECT_INVALID', '课程下载地址无效'); - } - if ((target.protocol !== 'http:' && target.protocol !== 'https:') - || target.origin !== apiOrigin - || Boolean(target.username || target.password)) { - throw new LearningCourseLibraryError('LEARNING_DOWNLOAD_REDIRECT_INVALID', '课程下载地址不安全'); - } - if (visited.has(target.href)) { - throw new LearningCourseLibraryError('LEARNING_DOWNLOAD_REDIRECT_LOOP', '课程下载地址存在重定向循环'); - } - visited.add(target.href); - assertCurrentAccount(binding); - response = await fetchImpl(target, { - headers: { Accept: 'application/zip' }, - redirect: 'manual', - }); - assertCurrentAccount(binding); - currentUrl = target.href; - } - return response; - } - - async function install( - courseId: string, - binding: WorksSquareAccountBinding, - ): Promise { - assertCourseId(courseId); - const metadataResponse = await authorizedFetch( - `/api/learning/courses/${encodeURIComponent(courseId)}`, - binding, - ); - const metadataPayload = await safeJson(metadataResponse); - assertCurrentAccount(binding); - if (!metadataResponse.ok) throw upstreamError(metadataPayload, metadataResponse.status); - const course = parseCourse(metadataPayload); - if (course.id !== courseId) { - throw new LearningCourseLibraryError('LEARNING_COURSE_ID_MISMATCH', '课程信息与请求不匹配'); - } - - const targetDirectory = courseDirectory(dependencies.rootDirectory, binding.accountKey, course); - const archivePath = join(targetDirectory, 'course.makelore-course.zip'); - const descriptorPath = join(targetDirectory, 'installed.json'); - try { - const existing = parseInstalled(JSON.parse(await readFile(descriptorPath, 'utf8'))); - if (existing && existing.course.archiveSha256 === course.archiveSha256 - && (await stat(archivePath)).size === course.archiveBytes - && await fileSha256(archivePath) === course.archiveSha256) { - assertCurrentAccount(binding); - return toPublicRecord(existing); - } - } catch { - assertCurrentAccount(binding); - // Missing or incomplete installs are replaced atomically below. - } - - await mkdir(targetDirectory, { recursive: true }); - assertCurrentAccount(binding); - const temporaryArchive = join(targetDirectory, `.course-${randomUUID()}.partial`); - const temporaryDescriptor = join(targetDirectory, `.installed-${randomUUID()}.partial`); - try { - const downloadPath = `/api/learning/courses/${encodeURIComponent(courseId)}/download`; - let downloadResponse = await authorizedFetch(downloadPath, binding); - downloadResponse = await followDownloadRedirect( - downloadResponse, - `${apiBaseUrl}${downloadPath}`, - binding, - ); - if (!downloadResponse.ok) { - const payload = await safeJson(downloadResponse); - assertCurrentAccount(binding); - throw upstreamError(payload, downloadResponse.status); - } - await streamVerifiedArchive( - downloadResponse, - temporaryArchive, - course, - () => assertCurrentAccount(binding), - ); - assertCurrentAccount(binding); - const installed: InstalledLearningCourseDescriptor = { - schemaVersion: 1, - course, - archivePath, - installedAt: now().toISOString(), - }; - await writeFile(temporaryDescriptor, JSON.stringify(installed, null, 2), { encoding: 'utf8', flag: 'wx' }); - assertCurrentAccount(binding); - await rename(temporaryArchive, archivePath); - await rename(temporaryDescriptor, descriptorPath); - assertCurrentAccount(binding); - return toPublicRecord(installed); - } catch (error) { - await Promise.all([ - rm(temporaryArchive, { force: true }), - rm(temporaryDescriptor, { force: true }), - ]); - throw error; - } - } - - async function listInstalledDescriptors( - binding: WorksSquareAccountBinding, - ): Promise { - assertCurrentAccount(binding); - const coursesRoot = accountCoursesDirectory(dependencies.rootDirectory, binding.accountKey); - let courseDirectories; - try { - courseDirectories = await readdir(coursesRoot, { withFileTypes: true }); - assertCurrentAccount(binding); - } catch { - assertCurrentAccount(binding); - return []; - } - const records: InstalledLearningCourseDescriptor[] = []; - for (const courseEntry of courseDirectories) { - if (!courseEntry.isDirectory() || !COURSE_ID_PATTERN.test(courseEntry.name)) continue; - const hashRoot = join(coursesRoot, courseEntry.name); - const hashDirectories = await readdir(hashRoot, { withFileTypes: true }).catch(() => []); - assertCurrentAccount(binding); - for (const hashEntry of hashDirectories) { - if (!hashEntry.isDirectory() || !SHA256_PATTERN.test(hashEntry.name)) continue; - const descriptorPath = join(hashRoot, hashEntry.name, 'installed.json'); - try { - const record = parseInstalled(JSON.parse(await readFile(descriptorPath, 'utf8'))); - if (!record - || record.course.id !== courseEntry.name - || record.course.contentHash !== hashEntry.name - || dirname(record.archivePath) !== join(hashRoot, hashEntry.name) - || (await stat(record.archivePath)).size !== record.course.archiveBytes) continue; - assertCurrentAccount(binding); - records.push(record); - } catch { - assertCurrentAccount(binding); - // Ignore incomplete or corrupted entries; they can be downloaded again. - } - } - } - assertCurrentAccount(binding); - return records.sort((left, right) => right.installedAt.localeCompare(left.installedAt)); - } - - async function resolveClassroomForBinding( - courseId: string, - moduleId: string | undefined, - binding: WorksSquareAccountBinding, - ): Promise<{ classroom: LearningClassroomPayload; installed: InstalledLearningCourseDescriptor }> { - const installed = (await listInstalledDescriptors(binding)) - .find((record) => record.course.id === courseId); - if (!installed) { - throw new LearningCourseLibraryError('LEARNING_COURSE_NOT_INSTALLED', '课程尚未下载到本机'); - } - if (await fileSha256(installed.archivePath) !== installed.course.archiveSha256) { - throw new LearningCourseLibraryError('LEARNING_ARCHIVE_HASH_MISMATCH', '本地课程包完整性校验失败,请重新下载'); - } - assertCurrentAccount(binding); - const zip = new AdmZip(installed.archivePath); - for (const entry of zip.getEntries()) { - const parts = entry.entryName.replace(/\\/g, '/').split('/'); - if (entry.entryName.startsWith('/') || parts.includes('..')) { - throw new LearningCourseLibraryError('LEARNING_ARCHIVE_INVALID', '课程包包含不安全路径'); - } - } - try { - const classroom = await readLearningPackageClassroom(zip, installed.course, moduleId); - assertCurrentAccount(binding); - return { classroom, installed }; - } catch { - assertCurrentAccount(binding); - throw new LearningCourseLibraryError( - 'LEARNING_CLASSROOM_INVALID', - '课程播放数据无效', - ); - } - } - - return { - download(courseId: string): Promise { - assertCourseId(courseId); - const binding = requireAccountBinding(); - const downloadKey = `${binding.accountKey}:${binding.epoch}:${courseId}`; - const existing = activeDownloads.get(downloadKey); - if (existing) return existing; - const pending = withFixedErrors(() => install(courseId, binding)) - .finally(() => activeDownloads.delete(downloadKey)); - activeDownloads.set(downloadKey, pending); - return pending; - }, - - async listInstalled(): Promise { - const binding = requireAccountBinding(); - return withFixedErrors(async () => ( - (await listInstalledDescriptors(binding)).map(toPublicRecord) - )); - }, - - async resolveClassroom(courseId: string, moduleId?: string): Promise { - assertCourseId(courseId); - const binding = requireAccountBinding(); - return withFixedErrors(async () => { - const { classroom } = await resolveClassroomForBinding(courseId, moduleId, binding); - return classroom; - }); - }, - - async readClassroom(courseId: string, moduleId?: string): Promise { - assertCourseId(courseId); - const binding = requireAccountBinding(); - return withFixedErrors(async () => { - const { classroom, installed } = await resolveClassroomForBinding(courseId, moduleId, binding); - assertCurrentAccount(binding); - registerLearningCoursePackage( - binding.accountKey, - classroom.courseId, - classroom.courseContentHash, - installed.archivePath, - ); - assertCurrentAccount(binding); - return classroom; - }); - }, - }; -} diff --git a/electron/services/learning-generation-client.ts b/electron/services/learning-generation-client.ts deleted file mode 100644 index b794fdc..0000000 --- a/electron/services/learning-generation-client.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { WORKS_SQUARE_CONFIG } from '../api/works-config'; -import { proxyAwareFetch } from '../utils/proxy-fetch'; -import { getValidWorksSquareAccessToken } from './works-square-session'; -import { - LEARNING_MATERIAL_MAX_FILE_BYTES, - LEARNING_MATERIAL_MAX_FILES, - LEARNING_MATERIAL_MAX_TOTAL_BYTES, - LEARNING_GENERATION_CONTRACT_VERSION, - type LearningGeneration, - type LearningGenerationOptions, - type LearningGenerationUploadRequest, -} from '../../shared/learning'; - -type Dependencies = { - fetchImpl?: typeof fetch; - getAccessToken?: typeof getValidWorksSquareAccessToken; - apiBaseUrl?: string; -}; - -function record(value: unknown): Record { - return value && typeof value === 'object' && !Array.isArray(value) - ? value as Record - : {}; -} - -function nullableString(value: unknown, maxLength: number): string | null { - if (value === null) return null; - if (typeof value !== 'string' || value.length > maxLength) throw new Error('课程任务创建失败'); - return value; -} - -function nullableInteger(value: unknown, min: number, max: number): number | null { - if (value === null) return null; - if (!Number.isSafeInteger(value) || Number(value) < min || Number(value) > max) { - throw new Error('课程任务创建失败'); - } - return Number(value); -} - -function projectGeneration(value: unknown): LearningGeneration { - const generation = record(value); - const contractVersion = generation.contractVersion === undefined - ? undefined - : Number.isSafeInteger(generation.contractVersion) - && Number(generation.contractVersion) === LEARNING_GENERATION_CONTRACT_VERSION - ? Number(generation.contractVersion) - : null; - const jobId = typeof generation.jobId === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(generation.jobId) - ? generation.jobId - : null; - const status = typeof generation.status === 'string' && /^[a-z][a-z0-9_-]{0,63}$/.test(generation.status) - ? generation.status - : null; - const mode = generation.mode === undefined - ? undefined - : generation.mode === 'single' || generation.mode === 'large' ? generation.mode : null; - const progress = generation.progress === null ? null : generation.progress; - const courseId = generation.courseId === null - ? null - : typeof generation.courseId === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(generation.courseId) - ? generation.courseId - : undefined; - if (contractVersion === null || !jobId || !status || mode === null || typeof generation.done !== 'boolean' - || courseId === undefined - || (progress !== null && (typeof progress !== 'number' || !Number.isFinite(progress) || progress < 0 || progress > 100))) { - throw new Error('课程任务创建失败'); - } - return { - ...(contractVersion === undefined ? {} : { contractVersion }), - jobId, - status, - ...(mode === undefined ? {} : { mode }), - step: nullableString(generation.step, 128), - progress, - message: nullableString(generation.message, 2_000), - scenesGenerated: nullableInteger(generation.scenesGenerated, 0, 100_000), - totalScenes: nullableInteger(generation.totalScenes, 0, 100_000), - courseId, - error: generation.error === null ? null : (nullableString(generation.error, 2_000), '课程生成失败'), - done: generation.done, - }; -} - -const INVALID_UPLOAD_ERROR = '课程任务创建失败'; -const MATERIAL_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/; -const MIME_TYPE_PATTERN = /^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/; -const MAX_MATERIAL_TIMESTAMP = 8_640_000_000_000_000; - -function invalidUpload(): never { - throw new Error(INVALID_UPLOAD_ERROR); -} - -function hasControlCharacter(value: string): boolean { - return [...value].some((character) => { - const code = character.codePointAt(0) ?? 0; - return code <= 31 || code === 127; - }); -} - -function validateOptions(value: unknown): LearningGenerationOptions { - const options = record(value); - const rawRequirement = typeof options.requirement === 'string' ? options.requirement : ''; - const requirement = rawRequirement.trim(); - if (!requirement || rawRequirement.length > 4_000) invalidUpload(); - const booleanKeys = [ - 'enableWebSearch', - 'enableImageGeneration', - 'enableVideoGeneration', - 'enableTTS', - 'interactiveMode', - 'taskEngineMode', - ] as const; - for (const key of booleanKeys) { - if (typeof options[key] !== 'boolean') invalidUpload(); - } - return { - requirement, - enableWebSearch: options.enableWebSearch as boolean, - enableImageGeneration: options.enableImageGeneration as boolean, - enableVideoGeneration: options.enableVideoGeneration as boolean, - enableTTS: options.enableTTS as boolean, - interactiveMode: options.interactiveMode as boolean, - taskEngineMode: options.taskEngineMode as boolean, - }; -} - -function validateMaterials(value: unknown): Array<{ - name: string; - mimeType: string; - order: number; - bytes: Uint8Array; -}> { - if (!Array.isArray(value) || value.length > LEARNING_MATERIAL_MAX_FILES) invalidUpload(); - let totalBytes = 0; - const ids = new Set(); - const orders = new Set(); - const validated = value.map((candidate) => { - const material = record(candidate); - const id = material.id; - const name = material.name; - const mimeType = material.mimeType; - const size = material.size; - const lastModified = material.lastModified; - const order = material.order; - const bytes = material.bytes; - if (typeof id !== 'string' || !MATERIAL_ID_PATTERN.test(id) || ids.has(id) - || typeof name !== 'string' || !name.trim() || name.length > 255 - || name.includes('/') || name.includes('\\') || hasControlCharacter(name) - || typeof mimeType !== 'string' || mimeType.length > 128 || !MIME_TYPE_PATTERN.test(mimeType) - || !Number.isSafeInteger(size) || Number(size) <= 0 || Number(size) > LEARNING_MATERIAL_MAX_FILE_BYTES - || !Number.isSafeInteger(lastModified) || Number(lastModified) < 0 || Number(lastModified) > MAX_MATERIAL_TIMESTAMP - || !Number.isSafeInteger(order) || Number(order) < 1 || Number(order) > value.length - || orders.has(Number(order)) - || !(bytes instanceof Uint8Array) - || bytes.byteLength !== Number(size)) invalidUpload(); - ids.add(id); - orders.add(Number(order)); - totalBytes += bytes.byteLength; - if (totalBytes > LEARNING_MATERIAL_MAX_TOTAL_BYTES) invalidUpload(); - const projectedBytes = new Uint8Array(bytes.byteLength); - projectedBytes.set(bytes); - return { name, mimeType, order: Number(order), bytes: projectedBytes }; - }).sort((left, right) => left.order - right.order); - return validated; -} - -export function createLearningGenerationClient(dependencies: Dependencies = {}) { - const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch; - const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken; - const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); - - async function start( - input: LearningGenerationUploadRequest, - assertCurrentAccount: () => void = () => undefined, - ): Promise { - const upload = record(input); - const options = validateOptions(upload.options); - const materials = validateMaterials(upload.materials); - assertCurrentAccount(); - const token = await getAccessToken({ fetchImpl }).catch(() => { throw new Error('课程任务创建失败'); }); - assertCurrentAccount(); - if (!token) throw new Error('请先登录'); - - const form = materials.length > 0 ? new FormData() : null; - if (form) { - form.set('options', JSON.stringify(options)); - for (const { name, mimeType, bytes } of materials) { - form.append( - 'materials', - new Blob([bytes.buffer], { type: mimeType }), - name, - ); - } - } - const request = (accessToken: string) => { - assertCurrentAccount(); - return fetchImpl(`${apiBaseUrl}/api/learning/generations`, { - method: 'POST', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${accessToken}`, - ...(form ? {} : { 'Content-Type': 'application/json' }), - }, - body: form ?? JSON.stringify(options), - redirect: 'manual', - }); - }; - let response = await request(token).catch(() => { throw new Error('课程任务创建失败'); }); - assertCurrentAccount(); - if (response.status === 401) { - await response.body?.cancel().catch(() => undefined); - assertCurrentAccount(); - const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true }).catch(() => null); - assertCurrentAccount(); - if (refreshed) { - response = await request(refreshed).catch(() => { throw new Error('课程任务创建失败'); }); - assertCurrentAccount(); - } - } - const payload = await response.json().catch(() => null); - if (!response.ok || !payload) throw new Error('课程任务创建失败'); - return projectGeneration(payload); - } - - return { start }; -} diff --git a/electron/services/learning-package-consumer.ts b/electron/services/learning-package-consumer.ts deleted file mode 100644 index 98db1b8..0000000 --- a/electron/services/learning-package-consumer.ts +++ /dev/null @@ -1,520 +0,0 @@ -import AdmZip from 'adm-zip'; -import type { - LearningClassroomPayload, - LearningCourse, - LearningCourseModule, -} from '../../shared/learning'; - -const SHA256_PATTERN = /^[0-9a-f]{64}$/; -const MODULE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/; -const MAX_DOCUMENT_BYTES = 64 * 1024 * 1024; -const MAX_ARCHIVE_ENTRIES = 4_096; -const MAX_ARCHIVE_UNCOMPRESSED_BYTES = 512 * 1024 * 1024; -const MAX_ENTRY_NAME_BYTES = 1_024; -const MAX_COMPRESSION_RATIO = 200; -const MAX_MODULES = 128; -const MAX_SCENES_PER_MODULE = 10_000; -const MAX_JSON_DEPTH = 64; -const MAX_JSON_NODES = 1_000_000; -const UNSAFE_MEDIA_ERROR = '课程包包含不安全媒体资源'; - -/** - * Keep this list exactly in sync with COURSE_ASSET_MIME_TYPES in - * learning-player-server.ts. A media entry must be a passive browser resource - * that the player can serve, never a document, script, stylesheet, or plugin - * payload. The MIME value is part of the signed course manifest, so an - * extension alone is not sufficient for admission. - */ -const COURSE_MEDIA_MIME_BY_EXTENSION: Readonly> = { - '.gif': 'image/gif', - '.jpeg': 'image/jpeg', - '.jpg': 'image/jpeg', - '.aac': 'audio/aac', - '.m4a': 'audio/mp4', - '.mp3': 'audio/mpeg', - '.mp4': 'video/mp4', - '.ogg': 'audio/ogg', - '.otf': 'font/otf', - '.png': 'image/png', - '.ttf': 'font/ttf', - '.wav': 'audio/wav', - '.webm': 'video/webm', - '.webp': 'image/webp', - '.woff': 'font/woff', - '.woff2': 'font/woff2', -}; -const COURSE_MEDIA_ROOTS = new Set(['audio', 'media', 'fonts']); -const SAFE_MEDIA_SEGMENT_PATTERN = /^[A-Za-z0-9_-]+$/; - -type ModuleLocation = { - module: LearningCourseModule; - root: string; - kind: 'legacy' | 'frozen'; -}; - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function validateArchiveMetadata(zip: AdmZip): void { - const entries = zip.getEntries(); - if (entries.length === 0 || entries.length > MAX_ARCHIVE_ENTRIES) { - throw new Error('课程包结构过于复杂'); - } - let totalSize = 0; - for (const entry of entries) { - const size = entry.header.size; - const compressedSize = entry.header.compressedSize; - if (!Number.isSafeInteger(size) || size < 0 - || !Number.isSafeInteger(compressedSize) || compressedSize < 0 - || Buffer.byteLength(entry.entryName, 'utf8') > MAX_ENTRY_NAME_BYTES - || (!entry.isDirectory && size > MAX_DOCUMENT_BYTES)) { - throw new Error('课程包资源超出限制'); - } - totalSize += size; - if (!Number.isSafeInteger(totalSize) || totalSize > MAX_ARCHIVE_UNCOMPRESSED_BYTES) { - throw new Error('课程包资源超出限制'); - } - if (!entry.isDirectory && size >= 1024 * 1024 - && (compressedSize === 0 || size / compressedSize > MAX_COMPRESSION_RATIO)) { - throw new Error('课程包压缩数据异常'); - } - } -} - -function validateJsonComplexity(value: unknown): void { - const pending: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }]; - let nodes = 0; - while (pending.length > 0) { - const current = pending.pop()!; - nodes += 1; - if (current.depth > MAX_JSON_DEPTH || nodes > MAX_JSON_NODES) { - throw new Error('课程包 JSON 结构过于复杂'); - } - if (Array.isArray(current.value)) { - for (const child of current.value) pending.push({ value: child, depth: current.depth + 1 }); - } else if (isRecord(current.value)) { - for (const child of Object.values(current.value)) pending.push({ value: child, depth: current.depth + 1 }); - } - } -} - -function readEntry(zip: AdmZip, name: string): Promise { - const entry = zip.getEntry(name); - if (!entry || entry.isDirectory || entry.header.size > MAX_DOCUMENT_BYTES) { - return Promise.reject(new Error(`课程包缺少 ${name}`)); - } - return new Promise((resolveData, rejectData) => { - entry.getDataAsync((data, error) => { - if (error || data.byteLength !== entry.header.size || data.byteLength > MAX_DOCUMENT_BYTES) { - rejectData(new Error('课程包资源读取失败')); - } else { - resolveData(data); - } - }); - }); -} - -async function readJson(zip: AdmZip, name: string): Promise { - try { - const value = JSON.parse((await readEntry(zip, name)).toString('utf8')) as unknown; - validateJsonComplexity(value); - return value; - } catch { - throw new Error(`${name} 无效`); - } -} - -function safeModuleId(value: unknown, fallback: string): string { - return typeof value === 'string' && MODULE_ID_PATTERN.test(value) ? value : fallback; -} - -function safeCount(value: unknown, fallback: number): number { - return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : fallback; -} - -function safeHash(value: unknown, fallback: string): string { - return typeof value === 'string' && SHA256_PATTERN.test(value) ? value : fallback; -} - -function normalizeRoot(value: unknown): string | null { - if (typeof value !== 'string') return null; - const root = value.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); - if (!root || root.split('/').some((part) => part === '.' || part === '..')) return null; - return `${root}/`; -} - -function manifestRoots(zip: AdmZip): string[] { - return zip.getEntries() - .map((entry) => entry.entryName.replace(/\\/g, '/')) - .flatMap((name) => { - if (name === 'manifest.json') return ['']; - const match = name.match(/^(modules\/[^/]+\/)manifest\.json$/); - return match ? [match[1]] : []; - }) - .sort((left, right) => left.localeCompare(right, undefined, { numeric: true })); -} - -function courseJsonModules(value: unknown): Array> { - return isRecord(value) && Array.isArray(value.modules) - ? value.modules.filter(isRecord) - : []; -} - -async function frozenModuleMetadata( - zip: AdmZip, - root: string, - fallback: LearningCourseModule, -): Promise { - const bundle = await readJson(zip, `${root}bundle.json`); - const meta = isRecord(bundle) && isRecord(bundle.meta) ? bundle.meta : null; - if (!meta - || typeof meta.coursewareId !== 'string' - || !MODULE_ID_PATTERN.test(meta.coursewareId) - || !Number.isSafeInteger(meta.sceneCount) - || Number(meta.sceneCount) < 0 - || Number(meta.sceneCount) > MAX_SCENES_PER_MODULE - || typeof meta.contentHash !== 'string' - || !SHA256_PATTERN.test(meta.contentHash)) { - throw new Error(`${root}bundle.json 模块身份无效`); - } - return { - moduleId: meta.coursewareId, - title: typeof meta.stageName === 'string' && meta.stageName.trim() ? meta.stageName : fallback.title, - summary: fallback.summary, - sceneCount: Number(meta.sceneCount), - contentHash: meta.contentHash, - }; -} - -async function resolveModuleLocations(zip: AdmZip, course: LearningCourse): Promise { - const roots = manifestRoots(zip); - if (roots.length > MAX_MODULES || (course.modules?.length ?? 0) > MAX_MODULES) { - throw new Error('课程包模块数量超出限制'); - } - const rawCourseJson = zip.getEntry('course.json') ? await readJson(zip, 'course.json') : null; - const descriptors = courseJsonModules(rawCourseJson); - const declared = course.modules?.length ? course.modules : descriptors.map((value, index) => ({ - moduleId: safeModuleId(value.moduleId ?? value.id, `module-${index + 1}`), - title: typeof value.title === 'string' && value.title.trim() ? value.title : `模块 ${index + 1}`, - summary: typeof value.summary === 'string' ? value.summary : null, - sceneCount: safeCount(value.sceneCount, 0), - contentHash: safeHash(value.contentHash, course.contentHash), - })); - - if (declared.length > 0) { - if (declared.length > MAX_MODULES) throw new Error('课程包模块数量超出限制'); - const locations: ModuleLocation[] = []; - for (const [index, module] of declared.entries()) { - const descriptor = descriptors.find((value) => value.moduleId === module.moduleId || value.id === module.moduleId) - ?? descriptors[index]; - const preferredRoot = normalizeRoot(descriptor?.path ?? descriptor?.directory); - const candidates = [ - preferredRoot, - `modules/${module.moduleId}/`, - `modules/${index}/`, - `modules/${index + 1}/`, - roots[index], - ].filter((value): value is string => value !== null && value !== undefined); - const root = candidates.find((candidate) => zip.getEntry(`${candidate}manifest.json`)); - if (!root) throw new Error(`课程模块“${module.title}”缺少 frozen manifest`); - const metadata = await frozenModuleMetadata(zip, root, module); - if (metadata.moduleId !== module.moduleId || metadata.contentHash !== module.contentHash) { - throw new Error(`课程模块“${module.title}”内容校验不一致`); - } - locations.push({ module: { ...module, ...metadata, moduleId: module.moduleId }, root, kind: 'frozen' }); - } - return locations; - } - - if (roots.length > 0) { - return Promise.all(roots.map(async (root, index) => { - const fallback: LearningCourseModule = { - moduleId: roots.length === 1 ? 'main' : `module-${index + 1}`, - title: roots.length === 1 ? course.title : `模块 ${index + 1}`, - summary: roots.length === 1 ? course.summary : null, - sceneCount: roots.length === 1 ? course.sceneCount : 0, - contentHash: course.contentHash, - }; - const module = await frozenModuleMetadata(zip, root, fallback); - return { module, root, kind: 'frozen' as const }; - })); - } - - if (zip.getEntry('classroom.json')) { - return [{ - module: { - moduleId: 'main', - title: course.title, - summary: course.summary, - sceneCount: course.sceneCount, - contentHash: course.contentHash, - }, - root: '', - kind: 'legacy', - }]; - } - throw new Error('课程包缺少可播放模块'); -} - -function assetUrl(course: LearningCourse, entryName: string): string { - const encodedPath = entryName.split('/').map(encodeURIComponent).join('/'); - return `/course-assets/${encodeURIComponent(course.id)}/${course.contentHash}/${encodedPath}`; -} - -function hasMediaControlCharacter(value: string): boolean { - for (const character of value) { - const code = character.charCodeAt(0); - if ((code >= 0 && code <= 0x1f) - || (code >= 0x7f && code <= 0x9f) - || code === 0x2028 - || code === 0x2029) { - return true; - } - } - return false; -} - -function validateCourseMediaEntry(relativePath: string, mimeType: unknown): void { - if (typeof relativePath !== 'string' - || !relativePath - || relativePath.includes('\\') - || relativePath.startsWith('/') - || relativePath.endsWith('/') - || relativePath.includes('//') - || hasMediaControlCharacter(relativePath)) { - throw new Error(UNSAFE_MEDIA_ERROR); - } - - const segments = relativePath.split('/'); - if (segments.some((segment) => !segment || segment === '.' || segment === '..')) { - throw new Error(UNSAFE_MEDIA_ERROR); - } - // `location.root` is authoritative for the module and is prepended below. - // Keeping the manifest key module-relative prevents a duplicate - // `modules//` prefix in the immutable player URL. - if (!COURSE_MEDIA_ROOTS.has(segments[0] ?? '') || segments.length <= 1) { - throw new Error(UNSAFE_MEDIA_ERROR); - } - const filename = segments.at(-1)!; - const directorySegments = segments.slice(1, -1); - if (directorySegments.some((segment) => !SAFE_MEDIA_SEGMENT_PATTERN.test(segment))) { - throw new Error(UNSAFE_MEDIA_ERROR); - } - - const extensionStart = filename.lastIndexOf('.'); - if (extensionStart <= 0 || extensionStart === filename.length - 1) { - throw new Error(UNSAFE_MEDIA_ERROR); - } - const stem = filename.slice(0, extensionStart); - const extension = filename.slice(extensionStart).toLowerCase(); - // A second suffix is ambiguous (for example `lesson.html.png`). Reject it - // even when the final extension is otherwise safe. - if (!SAFE_MEDIA_SEGMENT_PATTERN.test(stem) || stem.includes('.')) { - throw new Error(UNSAFE_MEDIA_ERROR); - } - const expectedMimeTypes = COURSE_MEDIA_MIME_BY_EXTENSION[extension]; - const normalizedMimeType = typeof mimeType === 'string' ? mimeType.trim().toLowerCase() : ''; - if (!expectedMimeTypes - || hasMediaControlCharacter(normalizedMimeType) - || expectedMimeTypes !== normalizedMimeType) { - throw new Error(UNSAFE_MEDIA_ERROR); - } -} - -function mediaRefFromPath(path: string, mimeType?: unknown): string { - const relative = path.startsWith('media/') ? path.slice('media/'.length) : path; - const suffix = typeof mimeType === 'string' ? mimeType.split('/')[1] : ''; - if (suffix && relative.toLowerCase().endsWith(`.${suffix.toLowerCase()}`)) { - return relative.slice(0, -suffix.length - 1); - } - return relative.replace(/\.[^/.]+$/, ''); -} - -function rewriteDeep(value: unknown, mediaUrls: Map): unknown { - if (typeof value === 'string') return mediaUrls.get(value) ?? value; - if (Array.isArray(value)) return value.map((item) => rewriteDeep(item, mediaUrls)); - if (!isRecord(value)) return value; - return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rewriteDeep(item, mediaUrls)])); -} - -function rewriteVideoManifest(value: unknown, mediaUrls: Map): unknown { - if (!isRecord(value)) return value; - return Object.fromEntries(Object.entries(value).map(([key, entry]) => [ - mediaUrls.get(key) ?? key, - rewriteDeep(entry, mediaUrls), - ])); -} - -async function buildFrozenClassroom( - zip: AdmZip, - course: LearningCourse, - location: ModuleLocation, -): Promise<{ stage: Record; scenes: unknown[] }> { - const [manifestValue, bundleValue] = await Promise.all([ - readJson(zip, `${location.root}manifest.json`), - readJson(zip, `${location.root}bundle.json`), - readJson(zip, `${location.root}quiz/quiz.json`), - readJson(zip, `${location.root}knowledge/knowledge.json`), - ]); - if (!isRecord(manifestValue) - || !isRecord(manifestValue.stage) - || !Array.isArray(manifestValue.scenes) - || !Array.isArray(manifestValue.agents) - || manifestValue.agents.some((agent) => !isRecord(agent)) - || !isRecord(manifestValue.mediaIndex)) { - throw new Error('Frozen manifest 结构无效'); - } - if (manifestValue.scenes.length > MAX_SCENES_PER_MODULE) { - throw new Error('课程包场景数量超出限制'); - } - const bundle = isRecord(bundleValue) ? bundleValue : {}; - const meta = isRecord(bundle.meta) ? bundle.meta : {}; - const completeness = isRecord(bundle.completeness) ? bundle.completeness : {}; - if (completeness.complete !== true) throw new Error('课程模块资源不完整,无法离线播放'); - if (safeCount(meta.sceneCount, -1) !== manifestValue.scenes.length) { - throw new Error('课程模块场景数量不一致'); - } - if (safeHash(meta.contentHash, '') !== location.module.contentHash) { - throw new Error('课程模块内容哈希不一致'); - } - - const mediaUrls = new Map(); - for (const [relativePath, mediaValue] of Object.entries(manifestValue.mediaIndex)) { - if (!isRecord(mediaValue)) throw new Error(UNSAFE_MEDIA_ERROR); - validateCourseMediaEntry(relativePath, mediaValue.mimeType); - if (mediaValue.missing === true) continue; - const fullPath = `${location.root}${relativePath}`; - if (!zip.getEntry(fullPath)) throw new Error(`课程模块缺少资源 ${relativePath}`); - const url = assetUrl(course, fullPath); - mediaUrls.set(relativePath, url); - if (mediaValue.type === 'generated' || mediaValue.type === 'image') { - mediaUrls.set(mediaRefFromPath(relativePath, mediaValue.mimeType), url); - } - } - - const stageId = `learning_${course.id}_${location.module.moduleId}`; - const agentIds = manifestValue.agents.map((_agent, index) => `${stageId}_a${index}`); - const agents = manifestValue.agents.filter(isRecord).map((agent, index) => ({ - id: agentIds[index], - name: typeof agent.name === 'string' ? agent.name : `Agent ${index + 1}`, - role: typeof agent.role === 'string' ? agent.role : 'teacher', - persona: typeof agent.persona === 'string' ? agent.persona : '', - avatar: typeof agent.avatar === 'string' ? agent.avatar : '/avatars/teacher.png', - color: typeof agent.color === 'string' ? agent.color : '#3b82f6', - priority: typeof agent.priority === 'number' ? agent.priority : index, - ...(isRecord(agent.voiceConfig) ? { voiceConfig: agent.voiceConfig } : {}), - ...(isRecord(agent.voiceDesign) ? { voiceDesign: agent.voiceDesign } : {}), - })); - const fallbackAgentIndex = manifestValue.agents.findIndex((agent) => isRecord(agent) && agent.role !== 'teacher'); - const stageSource = manifestValue.stage; - const stage: Record = { - id: stageId, - name: typeof stageSource.name === 'string' ? stageSource.name : location.module.title, - ...(typeof stageSource.description === 'string' ? { description: stageSource.description } : {}), - ...(typeof stageSource.language === 'string' ? { languageDirective: stageSource.language } : {}), - ...(typeof stageSource.style === 'string' ? { style: stageSource.style } : {}), - createdAt: typeof stageSource.createdAt === 'number' ? stageSource.createdAt : Date.now(), - updatedAt: typeof stageSource.updatedAt === 'number' ? stageSource.updatedAt : Date.now(), - agentIds, - generatedAgentConfigs: agents, - ...(Array.isArray(stageSource.whiteboard) - ? { whiteboard: rewriteDeep(stageSource.whiteboard, mediaUrls) } - : {}), - ...(stageSource.interactiveMode === true ? { interactiveMode: true } : {}), - ...(stageSource.taskEngineMode === true ? { taskEngineMode: true } : {}), - ...(stageSource.videoManifest - ? { videoManifest: rewriteVideoManifest(stageSource.videoManifest, mediaUrls) } - : {}), - }; - const scenes = manifestValue.scenes.map((sceneValue, index) => { - if (!isRecord(sceneValue) || !isRecord(sceneValue.content)) { - throw new Error(`课程模块第 ${index + 1} 个场景无效`); - } - const actions = Array.isArray(sceneValue.actions) ? sceneValue.actions.map((actionValue) => { - if (!isRecord(actionValue)) return actionValue; - if (actionValue.type === 'speech' && typeof actionValue.audioRef === 'string') { - const { audioRef, ...rest } = actionValue; - const audioUrl = mediaUrls.get(audioRef); - if (!audioUrl) throw new Error(`课程旁白资源缺失:${audioRef}`); - return { ...rest, audioUrl }; - } - if (actionValue.type === 'discussion') { - const { agentIndex, agentId: legacyAgentId, ...rest } = actionValue; - const indexValue = typeof agentIndex === 'number' ? agentIndex : fallbackAgentIndex; - const agentId = agentIds[indexValue] || (typeof legacyAgentId === 'string' ? legacyAgentId : undefined); - return { ...rest, ...(agentId ? { agentId } : {}) }; - } - return rewriteDeep(actionValue, mediaUrls); - }) : undefined; - const multiAgent = isRecord(sceneValue.multiAgent) && sceneValue.multiAgent.enabled === true - ? { - enabled: true, - agentIds: Array.isArray(sceneValue.multiAgent.agentIndices) - ? sceneValue.multiAgent.agentIndices - .map((value) => typeof value === 'number' ? agentIds[value] : undefined) - .filter(Boolean) - : [], - ...(typeof sceneValue.multiAgent.directorPrompt === 'string' - ? { directorPrompt: sceneValue.multiAgent.directorPrompt } - : {}), - } - : undefined; - return { - id: `${stageId}_s${index}`, - stageId, - title: typeof sceneValue.title === 'string' ? sceneValue.title : `场景 ${index + 1}`, - order: safeCount(sceneValue.order, index), - type: typeof sceneValue.type === 'string' ? sceneValue.type : sceneValue.content.type, - content: rewriteDeep(sceneValue.content, mediaUrls), - ...(actions ? { actions } : {}), - ...(Array.isArray(sceneValue.whiteboards) - ? { whiteboards: rewriteDeep(sceneValue.whiteboards, mediaUrls) } - : {}), - ...(multiAgent ? { multiAgent } : {}), - createdAt: Date.now(), - updatedAt: Date.now(), - }; - }); - return { stage, scenes }; -} - -export async function readLearningPackageClassroom( - zip: AdmZip, - course: LearningCourse, - requestedModuleId?: string, -): Promise { - validateArchiveMetadata(zip); - const locations = await resolveModuleLocations(zip, course); - const selected = requestedModuleId - ? locations.find((location) => location.module.moduleId === requestedModuleId) - : locations[0]; - if (!selected) throw new Error('找不到指定课程模块'); - const classroomValue = selected.kind === 'legacy' - ? await readJson(zip, 'classroom.json') - : await buildFrozenClassroom(zip, course, selected); - if (isRecord(classroomValue) - && Array.isArray(classroomValue.scenes) - && classroomValue.scenes.length > MAX_SCENES_PER_MODULE) { - throw new Error('课程包场景数量超出限制'); - } - if (!isRecord(classroomValue) - || !isRecord(classroomValue.stage) - || typeof classroomValue.stage.id !== 'string' - || !Array.isArray(classroomValue.scenes) - || classroomValue.scenes.length !== selected.module.sceneCount) { - throw new Error('课程播放数据与模块信息不匹配'); - } - return { - courseId: course.id, - courseContentHash: course.contentHash, - contentHash: course.contentHash, - // Works treats modules as an aggregate-course concept. Keep the local - // synthetic `main` descriptor for playback metadata, but omit it from - // cloud progress/runtime context for a single-course package. - moduleId: course.capabilities.modular === true ? selected.module.moduleId : null, - moduleContentHash: selected.module.contentHash, - modules: locations.map((location) => location.module), - classroom: classroomValue as { stage: Record; scenes: unknown[] }, - }; -} diff --git a/electron/services/learning-player-server.ts b/electron/services/learning-player-server.ts deleted file mode 100644 index 6ab60bd..0000000 --- a/electron/services/learning-player-server.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { createServer, type Server, type ServerResponse } from 'node:http'; -import { createHash, randomBytes } from 'node:crypto'; -import { readFile, stat } from 'node:fs/promises'; -import { extname, resolve, sep } from 'node:path'; -import AdmZip from 'adm-zip'; - -const MIME_TYPES: Record = { - '.css': 'text/css; charset=utf-8', - '.html': 'text/html; charset=utf-8', - '.aac': 'audio/aac', - '.gif': 'image/gif', - '.jpeg': 'image/jpeg', - '.jpg': 'image/jpeg', - '.js': 'text/javascript; charset=utf-8', - '.json': 'application/json; charset=utf-8', - '.m4a': 'audio/mp4', - '.mp3': 'audio/mpeg', - '.mp4': 'video/mp4', - '.ogg': 'audio/ogg', - '.png': 'image/png', - '.svg': 'image/svg+xml', - '.wav': 'audio/wav', - '.woff2': 'font/woff2', - '.woff': 'font/woff', - '.ttf': 'font/ttf', - '.otf': 'font/otf', - '.wasm': 'application/wasm', - '.webp': 'image/webp', - '.webm': 'video/webm', -}; - -// Course archives are untrusted content. Keep this narrower than the verified -// player artifact MIME table so a course asset can never become an executable -// same-origin document, script, stylesheet, PDF, or SVG image. -const COURSE_ASSET_MIME_TYPES: Readonly> = Object.freeze({ - '.aac': 'audio/aac', - '.gif': 'image/gif', - '.jpeg': 'image/jpeg', - '.jpg': 'image/jpeg', - '.m4a': 'audio/mp4', - '.mp3': 'audio/mpeg', - '.mp4': 'video/mp4', - '.ogg': 'audio/ogg', - '.otf': 'font/otf', - '.png': 'image/png', - '.ttf': 'font/ttf', - '.wav': 'audio/wav', - '.webm': 'video/webm', - '.webp': 'image/webp', - '.woff': 'font/woff', - '.woff2': 'font/woff2', -}); - -const COURSE_ASSET_CSP = "default-src 'none'; sandbox; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; - -function setCourseAssetSecurityHeaders(res: ServerResponse): void { - res.setHeader('Cache-Control', 'private, no-store'); - res.setHeader('Content-Security-Policy', COURSE_ASSET_CSP); - res.setHeader('Cross-Origin-Resource-Policy', 'same-origin'); - res.setHeader('X-Content-Type-Options', 'nosniff'); -} - -const COURSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; -const SHA256_PATTERN = /^[0-9a-f]{64}$/; -const registeredCoursePackages = new Map>(); -const PLAYER_COOKIE_NAME = 'makelore_learning_player'; - -function coursePackageKey(courseId: string, contentHash: string): string { - return `${courseId}:${contentHash}`; -} - -function validateAccountKey(accountKey: string): void { - if (!accountKey || accountKey.length > 256 || [...accountKey].some((character) => { - const code = character.codePointAt(0) ?? 0; - return code <= 31 || code === 127; - })) throw new Error('Invalid learning account partition'); -} - -/** Register only a verified installed archive; requests still receive an asset allowlist below. */ -export function registerLearningCoursePackage( - accountKey: string, - courseId: string, - contentHash: string, - archivePath: string, -): void { - validateAccountKey(accountKey); - if (!COURSE_ID_PATTERN.test(courseId) || !SHA256_PATTERN.test(contentHash)) { - throw new Error('Invalid learning course package identity'); - } - let accountPackages = registeredCoursePackages.get(accountKey); - if (!accountPackages) { - accountPackages = new Map(); - registeredCoursePackages.set(accountKey, accountPackages); - } - accountPackages.set(coursePackageKey(courseId, contentHash), resolve(archivePath)); -} - -export function unregisterLearningCoursePackage(accountKey: string, courseId: string, contentHash: string): boolean { - validateAccountKey(accountKey); - const accountPackages = registeredCoursePackages.get(accountKey); - const deleted = accountPackages?.delete(coursePackageKey(courseId, contentHash)) ?? false; - if (accountPackages?.size === 0) registeredCoursePackages.delete(accountKey); - return deleted; -} - -export function evictLearningCoursePackagesForAccount(accountKey: string): void { - validateAccountKey(accountKey); - registeredCoursePackages.delete(accountKey); -} - -export function assertLearningCoursePackageRegistered( - accountKey: string, - courseId: string, - contentHash: string, -): void { - validateAccountKey(accountKey); - if (!COURSE_ID_PATTERN.test(courseId) || !SHA256_PATTERN.test(contentHash)) { - throw new Error('Invalid learning course package identity'); - } - if (sharedServerAccountKey !== accountKey - || !registeredCoursePackages.get(accountKey)?.has(coursePackageKey(courseId, contentHash))) { - throw new Error('Learning course package is not registered for the active account'); - } -} - -function readZipEntry(zip: AdmZip, entryName: string): Promise { - const entry = zip.getEntry(entryName); - if (!entry || entry.isDirectory || entry.header.size > 256 * 1024 * 1024) { - return Promise.reject(new Error('Course asset is unavailable')); - } - return new Promise((resolveData, rejectData) => { - entry.getDataAsync((data, error) => error ? rejectData(error) : resolveData(data)); - }); -} - -async function serveCourseAsset(accountKey: string, pathname: string): Promise<{ bytes: Buffer; entryName: string } | null> { - const prefix = '/course-assets/'; - if (!pathname.startsWith(prefix)) return null; - const segments = pathname.slice(prefix.length).split('/').map((part) => decodeURIComponent(part)); - const [courseId, contentHash, ...entryParts] = segments; - const entryName = entryParts.join('/'); - if (!COURSE_ID_PATTERN.test(courseId || '') - || !SHA256_PATTERN.test(contentHash || '') - || entryParts.some((part) => !part || part === '.' || part === '..') - || !/^(?:(?:modules\/[^/]+)\/)?(?:audio|fonts|media)\/.+/.test(entryName) - || !COURSE_ASSET_MIME_TYPES[extname(entryName).toLowerCase()]) { - throw new Error('Invalid course asset path'); - } - const archivePath = registeredCoursePackages.get(accountKey)?.get(coursePackageKey(courseId, contentHash)); - if (!archivePath) throw new Error('Course package is not registered'); - return { bytes: await readZipEntry(new AdmZip(archivePath), entryName), entryName }; -} - -function isInside(root: string, target: string): boolean { - return target === root || target.startsWith(`${root}${sep}`); -} - -export async function resolveLearningPlayerArtifactRoot(explicitRoot?: string): Promise { - const candidates = [ - explicitRoot, - process.env.MAKELORE_LEARNING_PLAYER_ROOT, - process.resourcesPath ? resolve(process.resourcesPath, 'resources', 'learning-player') : undefined, - resolve(process.cwd(), 'build', 'learning-player'), - ].filter((value): value is string => Boolean(value)); - for (const candidate of candidates) { - const root = resolve(candidate); - try { - const artifact = JSON.parse(await readFile(resolve(root, 'artifact.json'), 'utf8')) as Record; - const indexPath = resolve(root, 'index.html'); - const html = await readFile(indexPath); - if (artifact.schemaVersion !== 1 - || artifact.entrypoint !== 'index.html' - || typeof artifact.htmlSha256 !== 'string' - || createHash('sha256').update(html).digest('hex') !== artifact.htmlSha256 - || !await stat(resolve(root, '_next', 'static')).then((entry) => entry.isDirectory()).catch(() => false) - || !await stat(resolve(root, 'avatars')).then((entry) => entry.isDirectory()).catch(() => false) - || !await stat(resolve(root, 'public')).then((entry) => entry.isDirectory()).catch(() => false)) continue; - return root; - } catch { - // Try the next verified artifact location. - } - } - throw new Error('学习播放器资源未随安装包提供,请重新安装 Makelore'); -} - -export async function createLearningPlayerServer(options: { accountKey: string; artifactRoot?: string }): Promise<{ - url: string; - close: () => Promise; -}> { - validateAccountKey(options.accountKey); - const root = await resolveLearningPlayerArtifactRoot(options.artifactRoot); - const indexPath = resolve(root, 'index.html'); - const staticRoot = resolve(root, '_next', 'static'); - const avatarRoot = resolve(root, 'avatars'); - const publicRoot = resolve(root, 'public'); - const nonce = randomBytes(32).toString('base64url'); - const noncePrefix = `/${nonce}`; - const expectedCookie = `${PLAYER_COOKIE_NAME}=${nonce}`; - let expectedHost = ''; - let closed = false; - - const server: Server = createServer(async (req, res) => { - try { - if (closed) { - res.writeHead(404).end(); - return; - } - if (req.method !== 'GET' && req.method !== 'HEAD') { - res.writeHead(404).end(); - return; - } - if (!expectedHost || req.headers.host !== expectedHost) { - res.writeHead(404).end(); - return; - } - const url = new URL(req.url || '/', 'http://127.0.0.1'); - if (url.pathname.includes('/course-assets/')) setCourseAssetSecurityHeaders(res); - const hasNoncePath = url.pathname === noncePrefix || url.pathname.startsWith(`${noncePrefix}/`); - const hasNonceCookie = (req.headers.cookie || '').split(';').some((part) => part.trim() === expectedCookie); - if (!hasNoncePath && !hasNonceCookie) { - res.writeHead(404).end(); - return; - } - const pathname = hasNoncePath ? url.pathname.slice(noncePrefix.length) || '/' : url.pathname; - if (hasNoncePath) { - res.setHeader('Set-Cookie', `${expectedCookie}; HttpOnly; SameSite=Strict; Path=/`); - } - if (pathname.startsWith('/course-assets/')) { - const asset = await serveCourseAsset(options.accountKey, pathname); - if (!asset) throw new Error('Course asset is unavailable'); - const contentType = COURSE_ASSET_MIME_TYPES[extname(asset.entryName).toLowerCase()]; - if (!contentType) throw new Error('Course asset type is unavailable'); - res.statusCode = 200; - res.setHeader('Content-Type', contentType); - res.setHeader('Content-Length', String(asset.bytes.byteLength)); - res.end(req.method === 'HEAD' ? undefined : asset.bytes); - return; - } - let target: string; - if (pathname === '/' || pathname === '/index.html' || pathname === '/makelore-player') { - target = indexPath; - res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; frame-src 'self' data: blob:; object-src 'none'; base-uri 'none'; form-action 'none'"); - res.setHeader('Cache-Control', 'no-store'); - } else if (pathname.startsWith('/_next/static/')) { - const relativePath = decodeURIComponent(pathname.slice('/_next/static/'.length)); - target = resolve(staticRoot, relativePath); - if (!isInside(staticRoot, target)) { - res.writeHead(404).end(); - return; - } - res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); - } else if (pathname.startsWith('/avatars/')) { - const relativePath = decodeURIComponent(pathname.slice('/avatars/'.length)); - target = resolve(avatarRoot, relativePath); - if (!isInside(avatarRoot, target)) { - res.writeHead(404).end(); - return; - } - res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); - } else { - // OpenMAIC's production Stage references a small public asset tree - // (for example PBL marks and vendor/fonts) with root-relative URLs. - // Resolve those URLs only inside the verified artifact's public root. - const relativePath = decodeURIComponent(pathname.slice(1)); - target = resolve(publicRoot, relativePath); - if (!relativePath || !isInside(publicRoot, target)) { - res.writeHead(404).end(); - return; - } - res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); - } - const bytes = await readFile(target); - res.statusCode = 200; - res.setHeader('Content-Type', MIME_TYPES[extname(target)] || 'application/octet-stream'); - res.setHeader('Content-Length', String(bytes.byteLength)); - res.end(req.method === 'HEAD' ? undefined : bytes); - } catch { - res.writeHead(404).end(); - } - }); - - await new Promise((resolveListen, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', () => resolveListen()); - }); - server.unref(); - const address = server.address(); - if (!address || typeof address === 'string') throw new Error('Learning player failed to bind'); - expectedHost = `127.0.0.1:${address.port}`; - let closePromise: Promise | null = null; - return { - url: `http://${expectedHost}${noncePrefix}/makelore-player?embedded=1`, - close: () => { - closed = true; - closePromise ??= server.listening - ? new Promise((resolveClose, reject) => { - server.close((error) => error ? reject(error) : resolveClose()); - server.closeAllConnections(); - }) - : Promise.resolve(); - return closePromise; - }, - }; -} - -let sharedServer: Promise>> | null = null; -let sharedServerAccountKey: string | null = null; -let sharedServerTransition: Promise = Promise.resolve(); - -export function getLearningPlayerServer(accountKey: string): Promise>> { - validateAccountKey(accountKey); - const operation = sharedServerTransition.then(async () => { - if (sharedServer && sharedServerAccountKey !== accountKey) { - const previous = sharedServer; - const previousAccountKey = sharedServerAccountKey; - sharedServer = null; - sharedServerAccountKey = null; - if (previousAccountKey) registeredCoursePackages.delete(previousAccountKey); - const previousServer = await previous.catch(() => null); - if (previousServer) await previousServer.close(); - } - if (!sharedServer) { - sharedServerAccountKey = accountKey; - sharedServer = createLearningPlayerServer({ accountKey }); - } - const pending = sharedServer; - try { - return await pending; - } catch (error) { - if (sharedServer === pending) { - sharedServer = null; - sharedServerAccountKey = null; - } - throw error; - } - }); - sharedServerTransition = operation.then(() => undefined, () => undefined); - return operation; -} - -export function closeLearningPlayerServer(): Promise { - const operation = sharedServerTransition.then(async () => { - const pending = sharedServer; - const accountKey = sharedServerAccountKey; - sharedServer = null; - sharedServerAccountKey = null; - if (accountKey) registeredCoursePackages.delete(accountKey); - if (pending) await (await pending).close(); - }); - sharedServerTransition = operation.then(() => undefined, () => undefined); - return operation; -} diff --git a/electron/services/learning-project-download.ts b/electron/services/learning-project-download.ts new file mode 100644 index 0000000..852b45a --- /dev/null +++ b/electron/services/learning-project-download.ts @@ -0,0 +1,241 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { open, rename, rm } from 'node:fs/promises'; +import { basename, dirname, extname, join } from 'node:path'; +import { + LEARNING_ARCHIVE_MAX_BYTES, + type LearningProjectDetail, +} from '../../shared/learning'; +import type { WorksSquareAccountBinding } from './works-square-session'; + +const PROJECT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +const SHA256_PATTERN = /^[0-9a-f]{64}$/; +const ARCHIVE_MIME_TYPES = new Set([ + 'application/octet-stream', + 'application/x-zip-compressed', + 'application/zip', +]); +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +export const LEARNING_PROJECT_DOWNLOAD_MAX_REDIRECTS = 5; + +export class LearningProjectDownloadError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + ) { + super(message); + this.name = 'LearningProjectDownloadError'; + } +} + +export function safeLearningProjectArchiveFileName(value: string, projectId: string): string { + const fallbackId = projectId.replace(/[^A-Za-z0-9_-]+/g, '-').slice(0, 48) || 'project'; + const normalized = [...basename(value.trim().replace(/\\/g, '/'))] + .map((character) => character.charCodeAt(0) < 32 ? '-' : character) + .join('') + .replace(/[<>:"/\\|?*]/g, '-') + .replace(/[. ]+$/g, '') + .slice(0, 120); + if (!normalized || extname(normalized).toLowerCase() !== '.zip') { + return `Makelore-${fallbackId}.zip`; + } + return normalized; +} + +function assertCurrentAccount( + binding: WorksSquareAccountBinding, + isCurrentAccountBinding: (value: WorksSquareAccountBinding) => boolean, +): void { + if (!isCurrentAccountBinding(binding)) { + throw new LearningProjectDownloadError(409, 'LEARNING_ACCOUNT_CHANGED', '登录账号已更改,请重新下载'); + } +} + +function validateProject(project: LearningProjectDetail): void { + if (!PROJECT_ID_PATTERN.test(project.id) + || !Number.isSafeInteger(project.archiveBytes) + || project.archiveBytes <= 0 + || project.archiveBytes > LEARNING_ARCHIVE_MAX_BYTES + || !SHA256_PATTERN.test(project.archiveSha256)) { + throw new LearningProjectDownloadError(502, 'LEARNING_PROJECT_INVALID', '项目下载信息无效'); + } +} + +async function followRedirects(input: { + response: Response; + initialUrl: string; + apiOrigin: string; + fetchImpl: typeof fetch; + binding: WorksSquareAccountBinding; + isCurrentAccountBinding: (value: WorksSquareAccountBinding) => boolean; +}): Promise { + let response = input.response; + let currentUrl = new URL(input.initialUrl).href; + const visited = new Set([currentUrl]); + + for (let hop = 0; REDIRECT_STATUSES.has(response.status); hop += 1) { + if (hop >= LEARNING_PROJECT_DOWNLOAD_MAX_REDIRECTS) { + await response.body?.cancel().catch(() => undefined); + throw new LearningProjectDownloadError(502, 'LEARNING_DOWNLOAD_REDIRECT_LIMIT', '项目下载重定向次数过多'); + } + const location = response.headers.get('location'); + await response.body?.cancel().catch(() => undefined); + if (!location) { + throw new LearningProjectDownloadError(502, 'LEARNING_DOWNLOAD_REDIRECT_INVALID', '项目下载地址无效'); + } + let target: URL; + try { + target = new URL(location, currentUrl); + } catch { + throw new LearningProjectDownloadError(502, 'LEARNING_DOWNLOAD_REDIRECT_INVALID', '项目下载地址无效'); + } + if (!['http:', 'https:'].includes(target.protocol) + || target.origin !== input.apiOrigin + || Boolean(target.username || target.password) + || visited.has(target.href)) { + throw new LearningProjectDownloadError(502, 'LEARNING_DOWNLOAD_REDIRECT_INVALID', '项目下载地址不安全'); + } + visited.add(target.href); + assertCurrentAccount(input.binding, input.isCurrentAccountBinding); + response = await input.fetchImpl(target, { + method: 'GET', + headers: { Accept: 'application/zip' }, + redirect: 'manual', + }); + currentUrl = target.href; + } + return response; +} + +async function writeVerifiedArchive(input: { + response: Response; + temporaryPath: string; + project: LearningProjectDetail; + binding: WorksSquareAccountBinding; + isCurrentAccountBinding: (value: WorksSquareAccountBinding) => boolean; +}): Promise { + if (!input.response.body) { + throw new LearningProjectDownloadError(502, 'LEARNING_DOWNLOAD_EMPTY', '项目压缩包内容为空'); + } + const contentType = input.response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); + if (!contentType || !ARCHIVE_MIME_TYPES.has(contentType)) { + await input.response.body.cancel().catch(() => undefined); + throw new LearningProjectDownloadError(502, 'LEARNING_ARCHIVE_MIME_INVALID', '项目压缩包类型无效'); + } + const declaredLength = Number(input.response.headers.get('content-length')); + if (Number.isFinite(declaredLength) && declaredLength !== input.project.archiveBytes) { + await input.response.body.cancel().catch(() => undefined); + throw new LearningProjectDownloadError(502, 'LEARNING_ARCHIVE_SIZE_MISMATCH', '项目压缩包大小校验失败'); + } + + const handle = await open(input.temporaryPath, 'wx'); + const hash = createHash('sha256'); + const signature: number[] = []; + let bytes = 0; + const reader = input.response.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + assertCurrentAccount(input.binding, input.isCurrentAccountBinding); + bytes += value.byteLength; + if (bytes > input.project.archiveBytes || bytes > LEARNING_ARCHIVE_MAX_BYTES) { + throw new LearningProjectDownloadError(502, 'LEARNING_ARCHIVE_SIZE_MISMATCH', '项目压缩包大小校验失败'); + } + for (const byte of value.subarray(0, Math.max(0, 4 - signature.length))) signature.push(byte); + hash.update(value); + await handle.write(value); + } + } finally { + reader.releaseLock(); + await handle.close(); + } + + if (bytes !== input.project.archiveBytes) { + throw new LearningProjectDownloadError(502, 'LEARNING_ARCHIVE_SIZE_MISMATCH', '项目压缩包大小校验失败'); + } + if (signature.length < 4 || signature[0] !== 0x50 || signature[1] !== 0x4b + || !((signature[2] === 0x03 && signature[3] === 0x04) + || (signature[2] === 0x05 && signature[3] === 0x06) + || (signature[2] === 0x07 && signature[3] === 0x08))) { + throw new LearningProjectDownloadError(502, 'LEARNING_ARCHIVE_INVALID', '项目压缩包不是有效的 ZIP 文件'); + } + if (hash.digest('hex') !== input.project.archiveSha256) { + throw new LearningProjectDownloadError(502, 'LEARNING_ARCHIVE_HASH_MISMATCH', '项目压缩包完整性校验失败'); + } +} + +export async function saveLearningProjectArchive(input: { + project: LearningProjectDetail; + destinationPath: string; + binding: WorksSquareAccountBinding; + fetchImpl: typeof fetch; + getAccessToken: (options?: { fetchImpl?: typeof fetch; forceRefresh?: boolean }) => Promise; + isCurrentAccountBinding: (value: WorksSquareAccountBinding) => boolean; + apiBaseUrl: string; +}): Promise { + validateProject(input.project); + assertCurrentAccount(input.binding, input.isCurrentAccountBinding); + const apiBaseUrl = input.apiBaseUrl.replace(/\/+$/, ''); + const archiveUrl = `${apiBaseUrl}/api/learning/projects/${encodeURIComponent(input.project.id)}/archive`; + const request = (accessToken: string) => input.fetchImpl(archiveUrl, { + method: 'GET', + headers: { + Accept: 'application/zip', + Authorization: `Bearer ${accessToken}`, + }, + redirect: 'manual', + }); + + const token = await input.getAccessToken({ fetchImpl: input.fetchImpl }); + assertCurrentAccount(input.binding, input.isCurrentAccountBinding); + if (!token) { + throw new LearningProjectDownloadError(401, 'LEARNING_AUTH_REQUIRED', '请先登录'); + } + let response = await request(token); + if (response.status === 401) { + await response.body?.cancel().catch(() => undefined); + const refreshed = await input.getAccessToken({ fetchImpl: input.fetchImpl, forceRefresh: true }); + assertCurrentAccount(input.binding, input.isCurrentAccountBinding); + if (!refreshed) { + throw new LearningProjectDownloadError(401, 'LEARNING_AUTH_REQUIRED', '请先登录'); + } + response = await request(refreshed); + } + response = await followRedirects({ + response, + initialUrl: archiveUrl, + apiOrigin: new URL(apiBaseUrl).origin, + fetchImpl: input.fetchImpl, + binding: input.binding, + isCurrentAccountBinding: input.isCurrentAccountBinding, + }); + if (!response.ok) { + await response.body?.cancel().catch(() => undefined); + throw new LearningProjectDownloadError( + response.status >= 400 && response.status <= 599 ? response.status : 502, + 'LEARNING_DOWNLOAD_FAILED', + '项目下载失败,请稍后重试', + ); + } + + const temporaryPath = join( + dirname(input.destinationPath), + `.${basename(input.destinationPath)}.${randomUUID()}.download`, + ); + try { + await writeVerifiedArchive({ + response, + temporaryPath, + project: input.project, + binding: input.binding, + isCurrentAccountBinding: input.isCurrentAccountBinding, + }); + assertCurrentAccount(input.binding, input.isCurrentAccountBinding); + await rename(temporaryPath, input.destinationPath); + } catch (error) { + await rm(temporaryPath, { force: true }).catch(() => undefined); + if (error instanceof LearningProjectDownloadError) throw error; + throw new LearningProjectDownloadError(500, 'LEARNING_SAVE_FAILED', '项目保存失败,请重新选择位置后重试'); + } +} diff --git a/electron/services/learning-runtime-bridge.ts b/electron/services/learning-runtime-bridge.ts deleted file mode 100644 index 2e93881..0000000 --- a/electron/services/learning-runtime-bridge.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { WORKS_SQUARE_CONFIG } from '../api/works-config'; -import { proxyAwareFetch } from '../utils/proxy-fetch'; -import { getValidWorksSquareAccessToken } from './works-square-session'; -import type { - LearningRuntimeBridgeEvent, - LearningRuntimeBridgeRequest, - LearningRuntimeCapability, -} from '../../shared/learning'; - -const COURSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; -const MODULE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]{1,128}$/; -const SHA256_PATTERN = /^[0-9a-f]{64}$/; -const REQUEST_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/; -const MAX_REQUEST_BYTES = 2 * 1024 * 1024; -const MAX_RESPONSE_BYTES = 64 * 1024 * 1024; -const MAX_CONTENT_TYPE_LENGTH = 128; -const CAPABILITIES = new Set([ - 'quiz-grade', - 'pbl/v2/task/update', - 'pbl/v2/open-task', - 'pbl/v2/simulator', - 'pbl/v2/instructor', - 'pbl/v2/evaluate', -]); - -type Dependencies = { - fetchImpl?: typeof fetch; - getAccessToken?: typeof getValidWorksSquareAccessToken; - apiBaseUrl?: string; -}; - -function capabilityPath(capability: LearningRuntimeCapability): string { - return capability.split('/').map(encodeURIComponent).join('/'); -} - -function sanitizeAnchor(value: unknown): { sceneId?: string; sceneOrder?: number } | undefined { - if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; - const candidate = value as Record; - const anchor = { - ...(typeof candidate.sceneId === 'string' && candidate.sceneId.length > 0 && candidate.sceneId.length <= 256 - ? { sceneId: candidate.sceneId } - : {}), - ...(Number.isSafeInteger(candidate.sceneOrder) && Number(candidate.sceneOrder) >= 0 - ? { sceneOrder: Number(candidate.sceneOrder) } - : {}), - }; - return Object.keys(anchor).length > 0 ? anchor : undefined; -} - -function validateRequest(request: LearningRuntimeBridgeRequest): string { - const anchor = sanitizeAnchor(request.context.anchor); - if (!REQUEST_ID_PATTERN.test(request.requestId) - || !COURSE_ID_PATTERN.test(request.courseId) - || !SHA256_PATTERN.test(request.contentHash) - || (request.context.moduleId !== null && !MODULE_ID_PATTERN.test(request.context.moduleId)) - || !SHA256_PATTERN.test(request.context.moduleContentHash) - || !anchor?.sceneId - || request.method !== 'POST' - || !CAPABILITIES.has(request.capability)) { - throw new Error('课堂联网能力请求无效'); - } - const body = JSON.stringify({ - context: { - moduleId: request.context.moduleId, - moduleContentHash: request.context.moduleContentHash, - anchor: { sceneId: anchor.sceneId, ...(anchor.sceneOrder !== undefined ? { sceneOrder: anchor.sceneOrder } : {}) }, - }, - body: request.body ?? {}, - }); - if (Buffer.byteLength(body) > MAX_REQUEST_BYTES) throw new Error('课堂联网请求过大'); - return body; -} - -function safeContentType(response: Response): string { - const value = response.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase(); - return value && value.length <= MAX_CONTENT_TYPE_LENGTH && /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(value) - ? value - : 'application/octet-stream'; -} - -export function createLearningRuntimeBridge(dependencies: Dependencies = {}) { - const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch; - const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken; - const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); - - async function request( - input: LearningRuntimeBridgeRequest, - emit: (event: LearningRuntimeBridgeEvent) => void, - assertCurrentAccount: () => void = () => undefined, - ): Promise { - try { - const body = validateRequest(input); - assertCurrentAccount(); - const token = await getAccessToken({ fetchImpl }); - assertCurrentAccount(); - if (!token) { - emit({ requestId: input.requestId, type: 'error', code: 'LEARNING_AUTH_REQUIRED', message: '请先登录后使用联网课堂能力' }); - return; - } - const fetchRequest = (accessToken: string) => { - assertCurrentAccount(); - return fetchImpl( - `${apiBaseUrl}/api/learning/courses/${encodeURIComponent(input.courseId)}/runtime/${capabilityPath(input.capability)}`, - { - method: 'POST', - headers: { - Accept: '*/*', - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - 'X-Course-Content-Hash': input.contentHash, - 'X-Content-Hash': input.contentHash, - }, - body, - redirect: 'manual', - }, - ); - }; - let response = await fetchRequest(token); - assertCurrentAccount(); - if (response.status === 401) { - await response.body?.cancel().catch(() => undefined); - assertCurrentAccount(); - const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true }); - assertCurrentAccount(); - if (refreshed) { - response = await fetchRequest(refreshed); - assertCurrentAccount(); - } - } - if (!response.ok) { - await response.body?.cancel().catch(() => undefined); - emit({ - requestId: input.requestId, - type: 'error', - code: response.status === 401 ? 'LEARNING_AUTH_REQUIRED' : 'LEARNING_RUNTIME_UNAVAILABLE', - message: response.status === 401 ? '请先登录后使用联网课堂能力' : '课堂联网能力暂时不可用', - }); - return; - } - emit({ - requestId: input.requestId, - type: 'start', - status: response.status, - contentType: safeContentType(response), - }); - if (response.body) { - const reader = response.body.getReader(); - let bytesRead = 0; - try { - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - bytesRead += chunk.value.byteLength; - if (bytesRead > MAX_RESPONSE_BYTES) throw new Error('课堂联网响应过大'); - emit({ requestId: input.requestId, type: 'chunk', chunk: new Uint8Array(chunk.value) }); - } - } finally { - reader.releaseLock(); - } - } - emit({ requestId: input.requestId, type: 'end' }); - } catch { - emit({ - requestId: input.requestId, - type: 'error', - code: 'LEARNING_RUNTIME_UNAVAILABLE', - message: '课堂联网能力暂时不可用', - }); - } - } - - return { request }; -} diff --git a/electron/services/learning-speech-client.ts b/electron/services/learning-speech-client.ts deleted file mode 100644 index 993cf58..0000000 --- a/electron/services/learning-speech-client.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { WORKS_SQUARE_CONFIG } from '../api/works-config'; -import { proxyAwareFetch } from '../utils/proxy-fetch'; -import { getValidWorksSquareAccessToken } from './works-square-session'; - -const MAX_AUDIO_BYTES = 26_214_400; -const MAX_TRANSCRIPT_LENGTH = 16_000; - -export type LearningSpeechRequest = { - audio: Uint8Array; - fileName: string; - mimeType: string; - language?: string; -}; - -type Dependencies = { - fetchImpl?: typeof fetch; - getAccessToken?: typeof getValidWorksSquareAccessToken; - apiBaseUrl?: string; -}; - -export function createLearningSpeechClient(dependencies: Dependencies = {}) { - const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch; - const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken; - const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); - - async function transcribe( - input: LearningSpeechRequest, - assertCurrentAccount: () => void = () => undefined, - ): Promise<{ text: string }> { - const audio = input.audio instanceof Uint8Array ? input.audio : new Uint8Array(input.audio); - if (!audio.byteLength || audio.byteLength > MAX_AUDIO_BYTES) throw new Error('语音长度不符合要求'); - assertCurrentAccount(); - const token = await getAccessToken({ fetchImpl }).catch(() => { throw new Error('语音识别失败'); }); - assertCurrentAccount(); - if (!token) throw new Error('请先登录'); - const form = new FormData(); - form.set('audio', new Blob([audio], { type: input.mimeType || 'audio/webm' }), input.fileName || 'voice.webm'); - if (input.language?.trim()) form.set('language', input.language.trim()); - const request = (accessToken: string) => { - assertCurrentAccount(); - return fetchImpl(`${apiBaseUrl}/api/speech/transcriptions`, { - method: 'POST', - headers: { Authorization: `Bearer ${accessToken}` }, - body: form, - }); - }; - let response = await request(token).catch(() => { throw new Error('语音识别失败'); }); - assertCurrentAccount(); - if (response.status === 401) { - await response.body?.cancel().catch(() => undefined); - assertCurrentAccount(); - const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true }).catch(() => null); - assertCurrentAccount(); - if (refreshed) { - response = await request(refreshed).catch(() => { throw new Error('语音识别失败'); }); - assertCurrentAccount(); - } - } - const payload = await response.json().catch(() => null) as { text?: unknown; detail?: unknown } | null; - const text = typeof payload?.text === 'string' ? payload.text.trim() : ''; - if (!response.ok || !text || text.length > MAX_TRANSCRIPT_LENGTH) throw new Error('语音识别失败'); - return { text }; - } - - return { transcribe }; -} diff --git a/package.json b/package.json index b63da88..c0f7377 100644 --- a/package.json +++ b/package.json @@ -34,11 +34,10 @@ "init": "pnpm install", "dev": "node scripts/run-cloud-image-workspace-dev.mjs", "dev:image-workspace:local": "node scripts/run-local-image-workspace-dev.mjs", - "build": "pnpm run sync:learning-player && pnpm run build:vite && node scripts/bundle-opencode.mjs && node scripts/run-electron-builder.mjs", + "build": "pnpm run build:vite && node scripts/bundle-opencode.mjs && node scripts/run-electron-builder.mjs", "build:vite": "node --max-old-space-size=6144 ./node_modules/vite/bin/vite.js build", "perf:budget": "node scripts/check-performance-budget.mjs", "bundle:opencode": "node scripts/bundle-opencode.mjs", - "sync:learning-player": "node scripts/sync-learning-player.mjs", "lint": "eslint . --fix", "lint:check": "eslint .", "typecheck": "tsc --noEmit", @@ -64,8 +63,8 @@ "node:download:win": "zx scripts/download-bundled-node.mjs --platform=win", "prep:win-binaries": "pnpm run uv:download:win && pnpm run node:download:win", "icons": "zx scripts/generate-icons.mjs", - "package": "pnpm run sync:learning-player && pnpm run build:vite && node scripts/bundle-opencode.mjs", - "package:stage:win-x64": "pnpm run sync:learning-player && pnpm run build:vite && node scripts/bundle-opencode.mjs --platform=win32 --arch=x64", + "package": "pnpm run build:vite && node scripts/bundle-opencode.mjs", + "package:stage:win-x64": "pnpm run build:vite && node scripts/bundle-opencode.mjs --platform=win32 --arch=x64", "package:mac": "pnpm run python:download:mac && pnpm run uv:download:mac && pnpm run package && node scripts/run-electron-builder.mjs --mac --publish never", "package:mac:local": "pnpm run python:download:mac && pnpm run uv:download:mac && SKIP_PREINSTALLED_SKILLS=1 pnpm run package && node scripts/run-electron-builder.mjs --mac --publish never", "package:win": "pnpm run python:download:win && pnpm run uv:download:win && pnpm run package:stage:win-x64 && node scripts/run-electron-builder.mjs --win --publish never", diff --git a/scripts/before-pack.cjs b/scripts/before-pack.cjs index 1cbcbc6..1895132 100644 --- a/scripts/before-pack.cjs +++ b/scripts/before-pack.cjs @@ -2,13 +2,6 @@ const { execFileSync } = require('node:child_process'); const { join } = require('node:path'); exports.default = async function beforePack() { - execFileSync(process.execPath, [ - join(__dirname, 'verify-learning-player-artifact.mjs'), - join(__dirname, '..', 'build', 'learning-player'), - ], { - cwd: join(__dirname, '..'), - stdio: 'inherit', - }); execFileSync(process.execPath, [join(__dirname, 'prepare-publish-runtime.mjs')], { cwd: join(__dirname, '..'), stdio: 'inherit', diff --git a/scripts/download-learning-player-artifact.mjs b/scripts/download-learning-player-artifact.mjs deleted file mode 100644 index 139f8da..0000000 --- a/scripts/download-learning-player-artifact.mjs +++ /dev/null @@ -1,62 +0,0 @@ -import { createHash } from 'node:crypto'; -import { appendFile, cp, mkdir, readdir, rm } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import AdmZip from 'adm-zip'; -import { validateLearningPlayerArtifact } from './learning-player-artifact.mjs'; - -const artifactUrl = process.env.LEARNING_PLAYER_ARTIFACT_URL?.trim(); -const expectedSha256 = process.env.LEARNING_PLAYER_ARTIFACT_SHA256?.trim().toLowerCase(); -if (!artifactUrl || !/^https:\/\//i.test(artifactUrl)) { - throw new Error('LEARNING_PLAYER_ARTIFACT_URL must be a versioned HTTPS artifact URL'); -} -if (!expectedSha256 || !/^[0-9a-f]{64}$/.test(expectedSha256)) { - throw new Error('LEARNING_PLAYER_ARTIFACT_SHA256 must be the pinned artifact digest'); -} - -const response = await fetch(artifactUrl, { redirect: 'follow' }); -if (!response.ok) throw new Error(`Learning player download failed (HTTP ${response.status})`); -const archive = Buffer.from(await response.arrayBuffer()); -const actualSha256 = createHash('sha256').update(archive).digest('hex'); -if (actualSha256 !== expectedSha256) { - throw new Error(`Learning player archive digest mismatch: expected ${expectedSha256}, got ${actualSha256}`); -} - -const root = resolve(import.meta.dirname, '..'); -const extractionRoot = resolve(root, 'build', '.learning-player-download'); -const outputRoot = resolve(root, 'build', 'learning-player-source'); -await Promise.all([ - rm(extractionRoot, { recursive: true, force: true }), - rm(outputRoot, { recursive: true, force: true }), -]); -await mkdir(extractionRoot, { recursive: true }); -const zip = new AdmZip(archive); -for (const entry of zip.getEntries()) { - const normalized = entry.entryName.replace(/\\/g, '/'); - const parts = normalized.split('/'); - if (normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized) || parts.includes('..')) { - throw new Error('Learning player archive contains an unsafe path'); - } -} -zip.extractAllTo(extractionRoot, true); - -async function findArtifactRoots(directory, depth = 0) { - const entries = await readdir(directory, { withFileTypes: true }); - if (entries.some((entry) => entry.isFile() && entry.name === 'artifact.json')) return [directory]; - if (depth >= 2) return []; - const nested = await Promise.all(entries - .filter((entry) => entry.isDirectory()) - .map((entry) => findArtifactRoots(resolve(directory, entry.name), depth + 1))); - return nested.flat(); -} - -const candidates = await findArtifactRoots(extractionRoot); -if (candidates.length !== 1) throw new Error('Learning player archive must contain exactly one artifact.json root'); -await validateLearningPlayerArtifact(candidates[0]); -await cp(candidates[0], outputRoot, { recursive: true }); -await validateLearningPlayerArtifact(outputRoot); -await rm(extractionRoot, { recursive: true, force: true }); - -if (process.env.GITHUB_ENV) { - await appendFile(process.env.GITHUB_ENV, `MAKELORE_LEARNING_PLAYER_ARTIFACT=${outputRoot}\n`); -} -process.stdout.write(`Downloaded verified learning player ${actualSha256} to ${outputRoot}\n`); diff --git a/scripts/learning-player-artifact.mjs b/scripts/learning-player-artifact.mjs deleted file mode 100644 index 7b0a8b3..0000000 --- a/scripts/learning-player-artifact.mjs +++ /dev/null @@ -1,46 +0,0 @@ -import { createHash } from 'node:crypto'; -import { readdir, readFile, stat } from 'node:fs/promises'; -import { resolve } from 'node:path'; - -async function hasFiles(directory) { - const entries = await readdir(directory, { withFileTypes: true }).catch(() => []); - for (const entry of entries) { - if (entry.isFile()) return true; - if (entry.isDirectory() && await hasFiles(resolve(directory, entry.name))) return true; - } - return false; -} - -export async function validateLearningPlayerArtifact(directory) { - const root = resolve(directory); - const [artifactText, html] = await Promise.all([ - readFile(resolve(root, 'artifact.json'), 'utf8'), - readFile(resolve(root, 'index.html')), - ]).catch((error) => { - throw new Error(`Invalid learning player artifact at ${root}: ${error.message}`); - }); - const artifact = JSON.parse(artifactText); - if (artifact.schemaVersion !== 1 - || artifact.entrypoint !== 'index.html' - || typeof artifact.htmlSha256 !== 'string' - || !/^[0-9a-f]{64}$/.test(artifact.htmlSha256)) { - throw new Error(`Invalid learning player artifact manifest at ${root}`); - } - const htmlSha256 = createHash('sha256').update(html).digest('hex'); - if (htmlSha256 !== artifact.htmlSha256) { - throw new Error(`Learning player index hash mismatch at ${root}`); - } - if (!(await stat(resolve(root, '_next', 'static')).then((entry) => entry.isDirectory()).catch(() => false)) - || !await hasFiles(resolve(root, '_next', 'static'))) { - throw new Error(`Learning player static assets are missing at ${root}`); - } - if (!(await stat(resolve(root, 'avatars')).then((entry) => entry.isDirectory()).catch(() => false)) - || !await hasFiles(resolve(root, 'avatars'))) { - throw new Error(`Learning player avatars are missing at ${root}`); - } - if (!(await stat(resolve(root, 'public')).then((entry) => entry.isDirectory()).catch(() => false)) - || !await hasFiles(resolve(root, 'public'))) { - throw new Error(`Learning player public assets are missing at ${root}`); - } - return { root, artifact, htmlSha256 }; -} diff --git a/scripts/learning-player-build-manifest.json b/scripts/learning-player-build-manifest.json deleted file mode 100644 index a8f35b2..0000000 --- a/scripts/learning-player-build-manifest.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "schemaVersion": 1, - "artifact": { - "manifest": "artifact.json", - "entrypoint": "index.html", - "requiredDirectories": ["_next/static", "avatars", "public"] - }, - "localSource": { - "environmentVariable": "MAKELORE_LEARNING_SOURCE", - "requiredScripts": ["build", "build:makelore-player"] - }, - "release": { - "artifactEnvironmentVariable": "MAKELORE_LEARNING_PLAYER_ARTIFACT", - "downloadUrlSecret": "LEARNING_PLAYER_ARTIFACT_URL", - "sha256Secret": "LEARNING_PLAYER_ARTIFACT_SHA256" - } -} diff --git a/scripts/sync-learning-player.mjs b/scripts/sync-learning-player.mjs deleted file mode 100644 index 6f495d6..0000000 --- a/scripts/sync-learning-player.mjs +++ /dev/null @@ -1,69 +0,0 @@ -import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { spawn } from 'node:child_process'; -import { resolve } from 'node:path'; -import { validateLearningPlayerArtifact } from './learning-player-artifact.mjs'; - -const makeloreRoot = resolve(import.meta.dirname, '..'); -const outputRoot = resolve(makeloreRoot, 'build', 'learning-player'); -const buildManifest = JSON.parse(await readFile( - resolve(import.meta.dirname, 'learning-player-build-manifest.json'), - 'utf8', -)); - -function run(command, args, cwd) { - return new Promise((resolveRun, rejectRun) => { - const child = spawn(command, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' }); - child.once('error', rejectRun); - child.once('exit', (code, signal) => { - if (code === 0) resolveRun(); - else rejectRun(new Error(`${command} ${args.join(' ')} failed (${signal || code})`)); - }); - }); -} - -async function buildLocalSource() { - if (process.env.CI || process.env.MAKELORE_RELEASE_BUILD === '1') { - throw new Error( - 'Release builds require MAKELORE_LEARNING_PLAYER_ARTIFACT. Download and verify the versioned OpenMAIC player before packaging.', - ); - } - const explicitSource = process.env.MAKELORE_LEARNING_SOURCE?.trim(); - if (!explicitSource) { - throw new Error( - 'Learning player input is required. Set MAKELORE_LEARNING_PLAYER_ARTIFACT to a verified artifact, or explicitly opt in to a local source build with MAKELORE_LEARNING_SOURCE.', - ); - } - const learningRoot = resolve(explicitSource); - const packageJson = JSON.parse(await readFile(resolve(learningRoot, 'package.json'), 'utf8').catch(() => { - throw new Error(`OpenMAIC source checkout is unavailable at ${learningRoot}`); - })); - for (const script of buildManifest.localSource.requiredScripts) { - if (typeof packageJson.scripts?.[script] !== 'string') { - throw new Error(`OpenMAIC source is missing required script: ${script}`); - } - } - const temporaryOutput = resolve(makeloreRoot, 'build', '.learning-player-local'); - await rm(temporaryOutput, { recursive: true, force: true }); - await run('pnpm', ['run', 'build'], learningRoot); - await run('pnpm', ['run', 'build:makelore-player', '--', temporaryOutput], learningRoot); - return temporaryOutput; -} - -const explicitArtifact = process.env.MAKELORE_LEARNING_PLAYER_ARTIFACT?.trim(); -const sourceRoot = explicitArtifact ? resolve(explicitArtifact) : await buildLocalSource(); -if (sourceRoot === outputRoot) { - throw new Error('MAKELORE_LEARNING_PLAYER_ARTIFACT must not point at build/learning-player'); -} -const verified = await validateLearningPlayerArtifact(sourceRoot); - -await rm(outputRoot, { recursive: true, force: true }); -await mkdir(resolve(outputRoot, '..'), { recursive: true }); -await cp(verified.root, outputRoot, { recursive: true }); -await writeFile(resolve(outputRoot, 'makelore-build.json'), JSON.stringify({ - schemaVersion: 1, - sourceKind: explicitArtifact ? 'versioned-artifact' : 'local-source-build', - htmlSha256: verified.htmlSha256, -}, null, 2)); -await validateLearningPlayerArtifact(outputRoot); - -process.stdout.write(`Synced verified OpenMAIC player to ${outputRoot}\n`); diff --git a/scripts/verify-learning-player-artifact.mjs b/scripts/verify-learning-player-artifact.mjs deleted file mode 100644 index 07cc989..0000000 --- a/scripts/verify-learning-player-artifact.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { resolve } from 'node:path'; -import { validateLearningPlayerArtifact } from './learning-player-artifact.mjs'; - -const root = resolve(process.argv[2] || 'build/learning-player'); -const result = await validateLearningPlayerArtifact(root); -process.stdout.write(`Verified Makelore learning player ${result.htmlSha256} at ${result.root}\n`); diff --git a/shared/learning.ts b/shared/learning.ts index 1d930d6..0861956 100644 --- a/shared/learning.ts +++ b/shared/learning.ts @@ -1,169 +1,47 @@ -/** Stable boundary between Makelore, downloaded course packages and Works Square. */ +/** Stable project-catalog boundary between Makelore and Works Square. */ export const LEARNING_API_PATH = '/api/works/learning'; -export const LEARNING_GENERATION_CONTRACT_VERSION = 2; -export const LEARNING_MATERIAL_MAX_FILES = 5; -export const LEARNING_MATERIAL_MAX_FILE_BYTES = 50 * 1024 * 1024; -export const LEARNING_MATERIAL_MAX_TOTAL_BYTES = 150 * 1024 * 1024; -/** Main rejects larger course archives before downloading any bytes. */ +/** Main rejects larger project archives before writing any bytes. */ export const LEARNING_ARCHIVE_MAX_BYTES = 512 * 1024 * 1024; +export const LEARNING_MEDIA_MAX_BYTES = 10 * 1024 * 1024; -export type LearningGenerationOptions = { - requirement: string; - enableWebSearch: boolean; - enableImageGeneration: boolean; - enableVideoGeneration: boolean; - enableTTS: boolean; - interactiveMode: boolean; - taskEngineMode: boolean; +export type LearningProjectImage = { + url: string; + alt: string; + width?: number; + height?: number; }; -export type LearningGenerationMaterialRef = { +export type LearningProjectSummary = { id: string; name: string; - mimeType: string; - size: number; - lastModified: number; - order: number; -}; - -/** IPC-only upload shape. File bytes are never persisted in Renderer state. */ -export type LearningGenerationMaterialUpload = LearningGenerationMaterialRef & { - bytes: Uint8Array; -}; - -export type LearningGenerationUploadRequest = { - options: LearningGenerationOptions; - materials: LearningGenerationMaterialUpload[]; -}; - -export type LearningRuntimeCapability = - | 'quiz-grade' - | 'pbl/v2/task/update' - | 'pbl/v2/open-task' - | 'pbl/v2/simulator' - | 'pbl/v2/instructor' - | 'pbl/v2/evaluate'; - -export type LearningRuntimeBridgeRequest = { - requestId: string; - courseId: string; - contentHash: string; - capability: LearningRuntimeCapability; - method: 'POST'; - context: { - moduleId: string | null; - moduleContentHash: string; - anchor: { - sceneId: string; - sceneOrder?: number; - }; - }; - body: unknown; -}; - -export type LearningRuntimeBridgeEvent = - | { requestId: string; type: 'start'; status: number; contentType: string } - | { requestId: string; type: 'chunk'; chunk: Uint8Array } - | { requestId: string; type: 'end' } - | { requestId: string; type: 'error'; code: string; message: string }; - -export type LearningSceneKind = 'slide' | 'interactive' | 'quiz' | 'pbl'; -export type LearningCourseStatus = 'generating' | 'ready' | 'published' | 'failed' | 'archived'; - -export type LearningSingleCourseCapabilities = { - modular?: false; - sceneKinds: LearningSceneKind[]; - hasAudio: boolean; - hasWhiteboard: boolean; - hasAgent: boolean; -}; - -export type LearningLargeCourseCapabilities = { - modular: true; - orderedModules: true; - productionStagePerModule: true; -}; - -export type LearningCourseCapabilities = LearningSingleCourseCapabilities | LearningLargeCourseCapabilities; - -export type LearningCourseModule = { - moduleId: string; - title: string; - summary: string | null; - sceneCount: number; - contentHash: string; -}; - -export type LearningCourse = { - id: string; - origin: 'user_single' | 'ops_large'; - status: LearningCourseStatus; - title: string; - summary: string | null; - language: string | null; - contentHash: string; - archiveSha256: string; + summary: string; + cover: LearningProjectImage; + tags: string[]; + version: string | null; archiveBytes: number; - formatVersion: number; - minPlayerVersion: string; - sceneCount: number; - /** Ordered learning units inside one product-level course. */ - modules?: LearningCourseModule[]; - capabilities: LearningCourseCapabilities; - createdAt: string; - publishedAt: string | null; -}; - -export type LearningProgress = { - courseId: string; - /** Aggregate course hash used by Works ownership and entitlement checks. */ - contentHash: string; - moduleId?: string | null; - moduleContentHash?: string | null; - sceneOrder: number; - actionIndex: number | null; - positionMs: number | null; - completed: boolean; + publishedAt: string; updatedAt: string; }; -export type LearningProgressWrite = Pick< - LearningProgress, - 'contentHash' | 'moduleId' | 'sceneOrder' | 'actionIndex' | 'positionMs' | 'completed' ->; - -export type InstalledLearningCourse = { - schemaVersion: 1; - course: LearningCourse; - installedAt: string; +export type LearningProjectDetail = LearningProjectSummary & { + readmeMarkdown: string; + archiveFileName: string; + archiveSha256: string; }; -export type LearningClassroomPayload = { - courseId: string; - /** Aggregate package identity used to address immutable local assets. */ - courseContentHash: string; - /** Aggregate hash kept as a compatibility alias for existing Stage bridges. */ - contentHash: string; - moduleId: string | null; - /** Selected frozen module hash; equals courseContentHash for legacy single-classroom packages. */ - moduleContentHash: string; - modules: LearningCourseModule[]; - classroom: { stage: Record; scenes: unknown[] }; +export type LearningProjectPage = { + items: LearningProjectSummary[]; + nextCursor: string | null; + total?: number; }; -export type LearningGeneration = { - contractVersion?: number; - jobId: string; - status: string; - mode?: 'single' | 'large'; - step: string | null; - progress: number | null; - message: string | null; - scenesGenerated: number | null; - totalScenes: number | null; - courseId: string | null; - error: string | null; - done: boolean; +export type LearningProjectListQuery = { + cursor?: string; + limit?: number; +}; + +export type LearningProjectDownloadResult = { + status: 'saved' | 'cancelled'; }; diff --git a/src/App.tsx b/src/App.tsx index cb430b7..a1097e4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,7 +43,7 @@ const Setup = lazy(() => import('./pages/Setup').then(({ Setup: component }) => const Login = lazy(() => import('./pages/Login').then(({ Login: component }) => ({ default: component }))); const ModuleSelection = lazy(() => import('./pages/ModuleSelection').then(({ ModuleSelection: component }) => ({ default: component }))); const Learning = lazy(() => import('./pages/Learning').then(({ Learning: component }) => ({ default: component }))); -const LearningCoursePlayer = lazy(() => import('./pages/Learning/CoursePlayer').then(({ LearningCoursePlayer: component }) => ({ default: component }))); +const LearningProjectDetail = lazy(() => import('./pages/Learning/ProjectDetail').then(({ LearningProjectDetail: component }) => ({ default: component }))); /** * Error Boundary to catch and display React rendering errors @@ -471,7 +471,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/components/layout/LearningSidebar.tsx b/src/components/layout/LearningSidebar.tsx index efa593b..506ff71 100644 --- a/src/components/layout/LearningSidebar.tsx +++ b/src/components/layout/LearningSidebar.tsx @@ -1,14 +1,6 @@ -import { useCallback, useEffect, useState } from 'react'; -import { BookOpen, Download, Loader2, Plus, Sparkles } from 'lucide-react'; +import { FolderKanban } from 'lucide-react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { - downloadLearningCourse, - fetchMyLearningCourses, - LEARNING_LIBRARY_CHANGED_EVENT, - listInstalledLearningCourses, -} from '@/lib/learning'; import { cn } from '@/lib/utils'; -import type { InstalledLearningCourse, LearningCourse } from '../../../shared/learning'; type LearningSidebarProps = { sidebarCollapsed: boolean; @@ -17,157 +9,29 @@ type LearningSidebarProps = { export function LearningSidebar({ sidebarCollapsed }: LearningSidebarProps) { const navigate = useNavigate(); const location = useLocation(); - const [installed, setInstalled] = useState>({}); - const [ownedCourses, setOwnedCourses] = useState([]); - const [loading, setLoading] = useState(true); - const [downloading, setDownloading] = useState(null); - const [error, setError] = useState(null); - - const loadCourses = useCallback(async () => { - setLoading(true); - setError(null); - try { - const [localCourses, personalCourses] = await Promise.all([ - listInstalledLearningCourses(), - fetchMyLearningCourses().catch(() => []), - ]); - setInstalled(Object.fromEntries(localCourses.map((record) => [record.course.id, record]))); - setOwnedCourses(personalCourses); - } catch (cause) { - setError(cause instanceof Error ? cause.message : '个人课程暂时无法读取'); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void loadCourses(); - const refresh = () => void loadCourses(); - window.addEventListener(LEARNING_LIBRARY_CHANGED_EVENT, refresh); - return () => window.removeEventListener(LEARNING_LIBRARY_CHANGED_EVENT, refresh); - }, [loadCourses]); - - const personalCourses = (() => { - const courses = new Map(); - for (const record of Object.values(installed)) courses.set(record.course.id, record.course); - for (const course of ownedCourses) courses.set(course.id, course); - return [...courses.values()]; - })(); - - const openCourse = useCallback(async (course: LearningCourse) => { - if (installed[course.id]) { - navigate(`/learning/course/${encodeURIComponent(course.id)}`); - return; - } - setDownloading(course.id); - setError(null); - try { - const record = await downloadLearningCourse(course.id); - setInstalled((current) => ({ ...current, [course.id]: record })); - navigate(`/learning/course/${encodeURIComponent(course.id)}`); - } catch (cause) { - setError(cause instanceof Error ? cause.message : '课程下载失败'); - } finally { - setDownloading(null); - } - }, [installed, navigate]); - - const catalogActive = location.pathname === '/learning'; + const active = location.pathname === '/learning' || location.pathname.startsWith('/learning/project/'); return ( -
+
+ ); } diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 72b7ff8..060c32c 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -14,8 +14,6 @@ import { useProjectConfigStore } from '@/stores/project-config'; import { useSettingsStore } from '@/stores/settings'; import { cn } from '@/lib/utils'; import type { SidebarPeekSource } from './sidebar-peek'; -import { UserProfileDialog } from '@/components/profile/UserProfileDialog'; -import { useCurrentUserProfile } from '@/hooks/use-current-user-profile'; const SIDEBAR_PEEK_CLOSE_DELAY_MS = 180; @@ -37,24 +35,9 @@ export function MainLayout() { const activeModule = getAiModuleForPath(location.pathname); const isProgrammingModule = activeModule === 'programming'; const isPaintingModule = activeModule === 'painting'; - const isLearningPlayer = location.pathname.startsWith('/learning/course/'); const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/'); const isChatWorkspace = location.pathname === '/opencode-chat'; const isInitializationSafeRoute = location.pathname === '/project-config' || !isProgrammingModule; - const { - profileRequired, - profileSyncState, - profileSyncError, - syncProfileNow, - } = useCurrentUserProfile(isLearningPlayer); - const [profileDialogOpen, setProfileDialogOpen] = useState(false); - const profileDialogShouldBeOpen = profileDialogOpen - || profileRequired; - const profileSyncFailure = isLearningPlayer - ? profileSyncError || (profileSyncState?.status === 'error' ? profileSyncState.error : null) - : null; - const learningContentBlocked = isLearningPlayer - && (profileSyncState?.status !== 'ready' || profileRequired); const handleSidebarPeekChange = useCallback((open: boolean, source: SidebarPeekSource) => { if (!sidebarCollapsed) return; @@ -108,7 +91,7 @@ export function MainLayout() {
{/* Title bar: drag region on macOS, icon + controls on Windows */} - {!isLearningPlayer ? ( - - ) : null} +
- {profileSyncFailure ? ( -
-
-

无法加载个人资料

-

{profileSyncFailure}

-
- - -
-
-
- ) : learningContentBlocked ? ( - profileRequired ? null : ( -
- 正在同步个人资料… -
- ) - ) : } + {initializationBlocked ? (
@@ -167,15 +127,6 @@ export function MainLayout() { ) : null}
- {isLearningPlayer ? ( - { - setProfileDialogOpen(open); - }} - /> - ) : null} ); } diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index eb59f79..2acb55c 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -57,12 +57,6 @@ const UNIFIED_CHANNELS = new Set([ 'update:cancelAutoInstall', ]); -const ALLOWED_EVENT_CHANNELS = new Set([ - 'learning:runtime-event', -] as const); - -export type AllowedIpcEventChannel = 'learning:runtime-event'; - function toUnifiedRequest(channel: string, args: unknown[]): UnifiedRequest { const splitIndex = channel.indexOf(':'); return { @@ -118,31 +112,6 @@ export async function invokeIpc(channel: string, ...args: unknown[]): Promise return invokeApi(channel, ...args); } -export function subscribeIpcEvent( - channel: AllowedIpcEventChannel, - listener: (payload: unknown) => void, -): () => void { - if (!ALLOWED_EVENT_CHANNELS.has(channel)) { - throw new AppError('PERMISSION', 'IPC event channel is not allowed', { - transport: 'ipc', - channel, - source: 'renderer-event-subscription', - }); - } - - const unsubscribe = window.electron.ipcRenderer.on(channel, listener); - let active = true; - return () => { - if (!active) return; - active = false; - if (typeof unsubscribe === 'function') { - unsubscribe(); - return; - } - window.electron.ipcRenderer.off(channel, listener); - }; -} - export async function invokeIpcWithRetry( channel: string, args: unknown[] = [], diff --git a/src/lib/learning-runtime-anchor.ts b/src/lib/learning-runtime-anchor.ts deleted file mode 100644 index e86643a..0000000 --- a/src/lib/learning-runtime-anchor.ts +++ /dev/null @@ -1,19 +0,0 @@ -export type LearningRuntimeAnchor = { - sceneId?: string; - sceneOrder?: number; -}; - -/** Copy only the scene identity accepted by the Works runtime boundary. */ -export function sanitizeLearningRuntimeAnchor(value: unknown): LearningRuntimeAnchor | undefined { - if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; - const candidate = value as Record; - const anchor = { - ...(typeof candidate.sceneId === 'string' && candidate.sceneId.length > 0 && candidate.sceneId.length <= 256 - ? { sceneId: candidate.sceneId } - : {}), - ...(Number.isSafeInteger(candidate.sceneOrder) && Number(candidate.sceneOrder) >= 0 - ? { sceneOrder: Number(candidate.sceneOrder) } - : {}), - }; - return Object.keys(anchor).length > 0 ? anchor : undefined; -} diff --git a/src/lib/learning.ts b/src/lib/learning.ts index 35c7678..797094b 100644 --- a/src/lib/learning.ts +++ b/src/lib/learning.ts @@ -1,22 +1,16 @@ +import { invokeIpc } from '@/lib/api-client'; import { AppError } from '@/lib/error-model'; -import { invokeIpc, subscribeIpcEvent } from '@/lib/api-client'; import { hostApiFetch } from '@/lib/host-api'; import { LEARNING_API_PATH, - type LearningCourse, - type InstalledLearningCourse, - type LearningProgress, - type LearningProgressWrite, - type LearningGeneration, - type LearningGenerationMaterialUpload, - type LearningGenerationOptions, - type LearningGenerationUploadRequest, - type LearningClassroomPayload, - type LearningRuntimeBridgeEvent, - type LearningRuntimeBridgeRequest, + LEARNING_MEDIA_MAX_BYTES, + type LearningProjectDetail, + type LearningProjectDownloadResult, + type LearningProjectListQuery, + type LearningProjectPage, } from '../../shared/learning'; -type Envelope = { +type LearningEnvelope = { success?: boolean; status?: number; code?: string; @@ -24,6 +18,21 @@ type Envelope = { data?: T; }; +type LearningProjectMedia = { + dataBase64: string; + mimeType: string; +}; + +const PROJECT_MEDIA_URL_PATTERN = /^\/api\/learning\/projects\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\/media\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +const PROJECT_MEDIA_MIME_TYPES = new Set([ + 'image/avif', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/webp', +]); +const MAX_MEDIA_BASE64_LENGTH = Math.ceil(LEARNING_MEDIA_MAX_BYTES / 3) * 4; + export class LearningApiError extends Error { constructor( readonly status: number, @@ -35,242 +44,113 @@ export class LearningApiError extends Error { } } -async function request(path: string, init?: RequestInit): Promise { - let envelope: Envelope; - try { - envelope = await hostApiFetch>(path, init); - } catch (error) { - const status = error instanceof AppError && typeof error.details?.status === 'number' - ? error.details.status - : 502; - throw new LearningApiError(status, 'LEARNING_REQUEST_FAILED', error instanceof Error ? error.message : '学习服务请求失败'); +function errorStatus(error: unknown): number { + return error instanceof AppError && typeof error.details?.status === 'number' + ? error.details.status + : 502; +} + +function errorCode(error: unknown, status: number): string { + if (error instanceof AppError && typeof error.details?.backendCode === 'string') { + return error.details.backendCode; } - if (!envelope.success || envelope.data === undefined) { + if (status === 401) return 'LEARNING_AUTH_REQUIRED'; + if (status === 404) return 'LEARNING_PROJECT_NOT_FOUND'; + return 'LEARNING_REQUEST_FAILED'; +} + +async function requestData(path: string, init?: RequestInit): Promise { + let response: LearningEnvelope; + try { + response = await hostApiFetch>(path, init); + } catch (error) { + const status = errorStatus(error); throw new LearningApiError( - envelope.status ?? 502, - envelope.code ?? 'LEARNING_REQUEST_FAILED', - envelope.error ?? '学习服务请求失败', + status, + errorCode(error, status), + error instanceof Error ? error.message : '学习项目请求失败', ); } - return envelope.data; -} -export function fetchLearningCourses(): Promise { - return request(`${LEARNING_API_PATH}/courses`); -} - -export function fetchMyLearningCourses(): Promise { - return request(`${LEARNING_API_PATH}/courses/mine`); -} - -export function fetchLearningProgress(): Promise { - return request(`${LEARNING_API_PATH}/progress`); -} - -export const DEFAULT_LEARNING_GENERATION_OPTIONS: Omit = { - enableWebSearch: false, - enableImageGeneration: false, - enableVideoGeneration: false, - enableTTS: true, - interactiveMode: true, - taskEngineMode: false, -}; - -function normalizeGenerationOptions( - input: string | LearningGenerationOptions, -): LearningGenerationOptions { - return typeof input === 'string' - ? { requirement: input, ...DEFAULT_LEARNING_GENERATION_OPTIONS } - : input; -} - -export function startLearningGeneration( - input: string | LearningGenerationOptions, - materials: LearningGenerationMaterialUpload[] = [], -): Promise { - const options = normalizeGenerationOptions(input); - if (materials.length > 0) { - const request: LearningGenerationUploadRequest = { options, materials }; - return invokeIpc('learning:startGeneration', request); + if (!response.success || response.data === undefined) { + throw new LearningApiError( + response.status ?? 502, + response.code ?? 'LEARNING_REQUEST_FAILED', + response.error ?? '学习项目请求失败', + ); } - return request(`${LEARNING_API_PATH}/generations`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(options), - }); + return response.data; } -export function fetchLearningGeneration(jobId: string): Promise { - return request(`${LEARNING_API_PATH}/generations/${encodeURIComponent(jobId)}`); +function listQuery(query: LearningProjectListQuery): string { + const params = new URLSearchParams(); + if (query.cursor?.trim()) params.set('cursor', query.cursor.trim()); + if (query.limit !== undefined) params.set('limit', String(query.limit)); + const encoded = params.toString(); + return encoded ? `?${encoded}` : ''; } -export function cancelLearningGeneration(jobId: string): Promise { - return request(`${LEARNING_API_PATH}/generations/${encodeURIComponent(jobId)}/cancel`, { +export function fetchLearningProjects( + query: LearningProjectListQuery = {}, +): Promise { + return requestData(`${LEARNING_API_PATH}/projects${listQuery(query)}`); +} + +export function fetchLearningProject(projectId: string): Promise { + return requestData(`${LEARNING_API_PATH}/projects/${encodeURIComponent(projectId)}`); +} + +export function downloadLearningProject( + projectId: string, +): Promise { + return requestData(`${LEARNING_API_PATH}/projects/${encodeURIComponent(projectId)}/download`, { method: 'POST', }); } -export function resumeLearningGeneration(jobId: string): Promise { - return request(`${LEARNING_API_PATH}/generations/${encodeURIComponent(jobId)}/resume`, { - method: 'POST', - }); -} - -export function finalizeLearningGeneration(jobId: string): Promise { - return request(`${LEARNING_API_PATH}/generations/${encodeURIComponent(jobId)}/finalize`, { - method: 'POST', - }); -} - -export const LEARNING_LIBRARY_CHANGED_EVENT = 'makelore:learning-library-changed'; - -export function saveLearningProgress( - courseId: string, - progress: LearningProgressWrite, -): Promise { - const payload: LearningProgressWrite = { - contentHash: progress.contentHash, - ...(progress.moduleId ? { moduleId: progress.moduleId } : {}), - sceneOrder: progress.sceneOrder, - actionIndex: progress.actionIndex, - positionMs: progress.positionMs, - completed: progress.completed, - }; - return request(`${LEARNING_API_PATH}/courses/${encodeURIComponent(courseId)}/progress`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); -} - -export async function downloadLearningCourse(courseId: string): Promise { - const record = await invokeIpc('learning:download', courseId); - if (typeof window !== 'undefined') { - window.dispatchEvent(new Event(LEARNING_LIBRARY_CHANGED_EVENT)); +export async function fetchLearningProjectMedia(mediaUrl: string): Promise { + if (!PROJECT_MEDIA_URL_PATTERN.test(mediaUrl)) { + throw new LearningApiError(400, 'LEARNING_INVALID_MEDIA_URL', '项目图片地址无效'); } - return { - schemaVersion: record.schemaVersion, - course: record.course, - installedAt: record.installedAt, - }; -} - -export async function listInstalledLearningCourses(): Promise { - const records = await invokeIpc('learning:listInstalled'); - return records.map((record) => ({ - schemaVersion: record.schemaVersion, - course: record.course, - installedAt: record.installedAt, - })); -} - -export type { LearningClassroomPayload } from '../../shared/learning'; - -export function readLearningClassroom(courseId: string, moduleId?: string): Promise { - return invokeIpc('learning:readClassroom', courseId, moduleId); -} - -export function getLearningPlayerUrl(): Promise { - return invokeIpc('learning:playerUrl'); -} - -export type LearningAgentRequest = { - courseId: string; - contentHash: string; - message: string; - history?: Array<{ role: 'user' | 'assistant'; content: string }>; - anchor?: { - sceneId?: string; - sceneOrder?: number; - sceneTitle?: string; - actionIndex?: number; - moduleId?: string | null; - moduleContentHash?: string; - }; -}; - -export function askLearningAgent(request: LearningAgentRequest): Promise<{ text: string }> { - return invokeIpc<{ text: string }>('learning:agentAsk', request); -} - -export function resetLearningAgent(courseId?: string, contentHash?: string): Promise { - return invokeIpc('learning:agentReset', courseId, contentHash); -} - -export function transcribeLearningSpeech(request: { - audio: Uint8Array; - fileName: string; - mimeType: string; - language?: string; -}): Promise<{ text: string }> { - return invokeIpc<{ text: string }>('learning:transcribe', request); -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function readLearningRuntimeEvent(payload: unknown): LearningRuntimeBridgeEvent | null { - if (!isRecord(payload) || typeof payload.requestId !== 'string' || payload.requestId.length === 0 || payload.requestId.length > 128) { - return null; + const localPath = mediaUrl.replace('/api/learning/', `${LEARNING_API_PATH}/`); + const payload = await hostApiFetch(localPath); + const mimeType = typeof payload?.mimeType === 'string' ? payload.mimeType.trim().toLowerCase() : ''; + const dataBase64 = typeof payload?.dataBase64 === 'string' ? payload.dataBase64 : ''; + if ( + !PROJECT_MEDIA_MIME_TYPES.has(mimeType) + || !dataBase64 + || dataBase64.length > MAX_MEDIA_BASE64_LENGTH + || !/^[A-Za-z0-9+/]*={0,2}$/.test(dataBase64) + || dataBase64.length % 4 === 1 + ) { + throw new LearningApiError(502, 'LEARNING_INVALID_MEDIA_RESPONSE', '项目图片返回了无效数据'); } - if (payload.type === 'start') { - if ( - !Number.isInteger(payload.status) - || (payload.status as number) < 100 - || (payload.status as number) > 599 - || typeof payload.contentType !== 'string' - || payload.contentType.length === 0 - || payload.contentType.length > 128 - ) { - return null; - } - return { - requestId: payload.requestId, - type: 'start', - status: payload.status as number, - contentType: payload.contentType, - }; - } - if (payload.type === 'chunk') { - if (!(payload.chunk instanceof Uint8Array) || payload.chunk.byteLength > 64 * 1024) return null; - return { requestId: payload.requestId, type: 'chunk', chunk: payload.chunk }; - } - if (payload.type === 'end') { - return { requestId: payload.requestId, type: 'end' }; - } - if (payload.type === 'error') { - if ( - typeof payload.code !== 'string' - || payload.code.length === 0 - || payload.code.length > 64 - || typeof payload.message !== 'string' - || payload.message.length === 0 - || payload.message.length > 256 - ) { - return null; - } - return { - requestId: payload.requestId, - type: 'error', - code: payload.code, - message: payload.message, - }; - } - return null; + return `data:${mimeType};base64,${dataBase64}`; } -export async function streamLearningRuntime( - request: LearningRuntimeBridgeRequest, - onEvent: (event: LearningRuntimeBridgeEvent) => void, -): Promise { - const unsubscribe = subscribeIpcEvent('learning:runtime-event', (payload) => { - const event = readLearningRuntimeEvent(payload); - if (event?.requestId === request.requestId) onEvent(event); - }); +export function isLearningProjectMediaUrl(value: string): boolean { + return PROJECT_MEDIA_URL_PATTERN.test(value); +} + +export function isSafeLearningCoverUrl(value: string): boolean { + if (PROJECT_MEDIA_URL_PATTERN.test(value)) return true; try { - await invokeIpc('learning:runtimeRequest', request); - } finally { - unsubscribe(); + const url = new URL(value); + return url.protocol === 'https:' && !url.username && !url.password; + } catch { + return false; } } + +export async function openLearningExternalLink(value: string): Promise { + let url: URL; + try { + url = new URL(value); + } catch { + throw new LearningApiError(400, 'LEARNING_INVALID_LINK', '项目链接无效'); + } + if (url.protocol !== 'https:' || url.username || url.password) { + throw new LearningApiError(400, 'LEARNING_INVALID_LINK', '项目链接无效'); + } + await invokeIpc('shell:openExternal', url.toString()); +} diff --git a/src/pages/Learning/CoursePlayer.tsx b/src/pages/Learning/CoursePlayer.tsx deleted file mode 100644 index d88cdd2..0000000 --- a/src/pages/Learning/CoursePlayer.tsx +++ /dev/null @@ -1,539 +0,0 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { ArrowLeft, Loader2 } from 'lucide-react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { Button } from '@/components/ui/button'; -import { invokeIpc } from '@/lib/api-client'; -import { sanitizeLearningRuntimeAnchor } from '@/lib/learning-runtime-anchor'; -import { useAuthStore } from '@/stores/auth'; -import { getProfileAccountKey } from '@/stores/user-profile'; -import { - askLearningAgent, - fetchLearningProgress, - getLearningPlayerUrl, - readLearningClassroom, - saveLearningProgress, - streamLearningRuntime, - transcribeLearningSpeech, - type LearningClassroomPayload, -} from '@/lib/learning'; -import type { LearningRuntimeBridgeEvent, LearningRuntimeCapability } from '../../../shared/learning'; - -function localProgressKey(accountKey: string, payload: LearningClassroomPayload): string { - return `makelore.learning.progress.${encodeURIComponent(accountKey)}.${payload.courseId}.${payload.moduleContentHash}`; -} - -const LEARNING_PROGRESS_THROTTLE_MS = 15_000; -type LearningProgressSnapshot = { - contentHash: string; - moduleId: string | null; - moduleContentHash: string | null; - sceneOrder: number; - actionIndex: number | null; - positionMs: number | null; - completed: boolean; -}; - -const LEARNING_RUNTIME_CAPABILITIES = new Set([ - 'quiz-grade', - 'pbl/v2/task/update', - 'pbl/v2/open-task', - 'pbl/v2/simulator', - 'pbl/v2/instructor', - 'pbl/v2/evaluate', -]); -const BRIDGE_REQUEST_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/; -const MAX_RUNTIME_BODY_BYTES = 2 * 1024 * 1024; - -function record(value: unknown): Record | null { - return value && typeof value === 'object' && !Array.isArray(value) - ? value as Record - : null; -} - -function validBridgeRequestId(value: unknown): value is string { - return typeof value === 'string' && BRIDGE_REQUEST_ID_PATTERN.test(value); -} - -function hasBoundedJsonSize(value: unknown): boolean { - try { - return new TextEncoder().encode(JSON.stringify(value ?? {})).byteLength <= MAX_RUNTIME_BODY_BYTES; - } catch { - return false; - } -} - -function sanitizeAgentBridgeRequest(value: unknown): Pick[0], 'message' | 'history' | 'anchor'> | null { - const request = record(value); - if (!request || typeof request.message !== 'string' || !request.message.trim() || request.message.length > 4_000) return null; - const history: NonNullable[0]['history']> | undefined = Array.isArray(request.history) - ? request.history.slice(-8).flatMap((item): NonNullable[0]['history']> => { - const entry = record(item); - return entry - && (entry.role === 'user' || entry.role === 'assistant') - && typeof entry.content === 'string' - && entry.content.length <= 4_000 - ? [{ role: entry.role, content: entry.content }] - : []; - }) - : undefined; - const rawAnchor = record(request.anchor); - const anchor = rawAnchor ? { - ...(typeof rawAnchor.sceneId === 'string' && rawAnchor.sceneId.length <= 256 ? { sceneId: rawAnchor.sceneId } : {}), - ...(Number.isSafeInteger(rawAnchor.sceneOrder) && Number(rawAnchor.sceneOrder) >= 0 ? { sceneOrder: Number(rawAnchor.sceneOrder) } : {}), - ...(typeof rawAnchor.sceneTitle === 'string' && rawAnchor.sceneTitle.length <= 512 ? { sceneTitle: rawAnchor.sceneTitle } : {}), - ...(Number.isSafeInteger(rawAnchor.actionIndex) && Number(rawAnchor.actionIndex) >= 0 ? { actionIndex: Number(rawAnchor.actionIndex) } : {}), - } : undefined; - return { message: request.message, ...(history ? { history } : {}), ...(anchor ? { anchor } : {}) }; -} - -function parsePlayerUrl(value: string): { url: string; origin: string } { - const parsed = new URL(value); - if (parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1' || parsed.username || parsed.password) { - throw new Error('课程播放器地址无效'); - } - return { url: parsed.href, origin: parsed.origin }; -} - -export function matchesLearningCloudProgress( - item: { courseId: string; contentHash: string; moduleId?: string | null }, - courseId: string, - course: { courseContentHash: string; moduleId: string | null; modules: unknown[] }, -): boolean { - const moduleMatches = course.moduleId !== null - ? item.moduleId === course.moduleId - : course.modules.length === 1 && (item.moduleId === 'main' || !item.moduleId); - return item.courseId === courseId - && item.contentHash === course.courseContentHash - && moduleMatches; -} - -export function LearningCoursePlayer() { - const navigate = useNavigate(); - const { courseId = '' } = useParams(); - const accountKey = useAuthStore((state) => getProfileAccountKey(state.user?.userId ?? state.user?.username)); - const iframeRef = useRef(null); - const bridgeGenerationRef = useRef(0); - const playerDocumentLoadedRef = useRef(false); - const playerBridgeActiveRef = useRef(false); - const classroomRequestGenerationRef = useRef(0); - const renderedSessionIdentityRef = useRef({ accountKey, courseId }); - const pendingCloudProgressRef = useRef<{ courseId: string; snapshot: LearningProgressSnapshot } | null>(null); - const progressFlushTimerRef = useRef(null); - const lastCloudProgressAtRef = useRef(0); - const flushProgressRef = useRef<(force?: boolean) => void>(() => undefined); - const [session, setSession] = useState<{ - accountKey: string; - playerUrl: string; - playerOrigin: string; - payload: LearningClassroomPayload; - initialProgress: { sceneOrder: number; actionIndex: number | null; positionMs: number | null; completed: boolean } | null; - } | null>(null); - const [playerReady, setPlayerReady] = useState(false); - const [loaded, setLoaded] = useState(false); - const [ownedError, setOwnedError] = useState<{ accountKey: string | null; message: string } | null>(null); - const [runtimeNotice, setRuntimeNotice] = useState(null); - - useLayoutEffect(() => { - renderedSessionIdentityRef.current = { accountKey, courseId }; - classroomRequestGenerationRef.current += 1; - bridgeGenerationRef.current += 1; - playerBridgeActiveRef.current = false; - }, [accountKey, courseId]); - - const activeSession = session?.accountKey === accountKey ? session : null; - const playerUrl = activeSession?.playerUrl ?? null; - const playerOrigin = activeSession?.playerOrigin ?? null; - const payload = activeSession?.payload ?? null; - const initialProgress = activeSession?.initialProgress ?? null; - const error = ownedError?.accountKey === accountKey ? ownedError.message : null; - - useEffect(() => () => { - void invokeIpc('learning:closePlayer').catch(() => undefined); - }, []); - - const flushCloudProgress = useCallback((force = false): void => { - const pending = pendingCloudProgressRef.current; - if (!pending) return; - const elapsed = Date.now() - lastCloudProgressAtRef.current; - if (!force && lastCloudProgressAtRef.current > 0 && elapsed < LEARNING_PROGRESS_THROTTLE_MS) { - if (progressFlushTimerRef.current !== null) window.clearTimeout(progressFlushTimerRef.current); - progressFlushTimerRef.current = window.setTimeout( - () => flushProgressRef.current(false), - LEARNING_PROGRESS_THROTTLE_MS - elapsed, - ); - return; - } - pendingCloudProgressRef.current = null; - if (progressFlushTimerRef.current !== null) { - window.clearTimeout(progressFlushTimerRef.current); - progressFlushTimerRef.current = null; - } - lastCloudProgressAtRef.current = Date.now(); - void saveLearningProgress(pending.courseId, pending.snapshot).catch(() => { - if (!pendingCloudProgressRef.current) pendingCloudProgressRef.current = pending; - if (progressFlushTimerRef.current === null) { - progressFlushTimerRef.current = window.setTimeout( - () => flushProgressRef.current(false), - LEARNING_PROGRESS_THROTTLE_MS, - ); - } - }); - }, []); - useEffect(() => { - flushProgressRef.current = flushCloudProgress; - }, [flushCloudProgress]); - - const queueCloudProgress = useCallback((nextCourseId: string, snapshot: LearningProgressSnapshot): void => { - pendingCloudProgressRef.current = { courseId: nextCourseId, snapshot }; - const elapsed = Date.now() - lastCloudProgressAtRef.current; - if (lastCloudProgressAtRef.current === 0 || elapsed >= LEARNING_PROGRESS_THROTTLE_MS) { - flushCloudProgress(true); - return; - } - if (progressFlushTimerRef.current === null) { - progressFlushTimerRef.current = window.setTimeout( - () => flushProgressRef.current(false), - LEARNING_PROGRESS_THROTTLE_MS - elapsed, - ); - } - }, [flushCloudProgress]); - - useEffect(() => { - const flushWhenHidden = () => { - if (document.visibilityState === 'hidden') flushCloudProgress(true); - }; - document.addEventListener('visibilitychange', flushWhenHidden); - return () => { - document.removeEventListener('visibilitychange', flushWhenHidden); - flushCloudProgress(true); - }; - }, [flushCloudProgress]); - - useEffect(() => { - pendingCloudProgressRef.current = null; - if (progressFlushTimerRef.current !== null) { - window.clearTimeout(progressFlushTimerRef.current); - progressFlushTimerRef.current = null; - } - lastCloudProgressAtRef.current = 0; - }, [accountKey, courseId]); - - const beginClassroomRequest = useCallback((requestAccountKey = accountKey, requestCourseId = courseId) => { - const request = { - accountKey: requestAccountKey, - courseId: requestCourseId, - generation: classroomRequestGenerationRef.current + 1, - }; - classroomRequestGenerationRef.current = request.generation; - return request; - }, [accountKey, courseId]); - - const isCurrentClassroomRequest = useCallback((request: { accountKey: string | null; courseId: string; generation: number }) => ( - renderedSessionIdentityRef.current.accountKey === request.accountKey - && renderedSessionIdentityRef.current.courseId === request.courseId - && classroomRequestGenerationRef.current === request.generation - ), []); - - const resolveProgress = useCallback(( - requestAccountKey: string, - nextPayload: LearningClassroomPayload, - cloudProgress: Awaited>, - ) => { - const local: typeof initialProgress = (() => { - try { - return JSON.parse(window.localStorage.getItem(localProgressKey(requestAccountKey, nextPayload)) || 'null') as typeof initialProgress; - } catch { - return null; - } - })(); - const remote = cloudProgress.find((item) => ( - matchesLearningCloudProgress(item, courseId, nextPayload) - )) ?? null; - return local ?? (remote ? { - sceneOrder: remote.sceneOrder, - actionIndex: remote.actionIndex, - positionMs: remote.positionMs, - completed: remote.completed, - } : null); - }, [courseId]); - - useEffect(() => { - const requestAccountKey = renderedSessionIdentityRef.current.accountKey; - const requestCourseId = renderedSessionIdentityRef.current.courseId; - if (!requestAccountKey) return; - const request = beginClassroomRequest(requestAccountKey, requestCourseId); - bridgeGenerationRef.current += 1; - playerBridgeActiveRef.current = false; - Promise.all([ - getLearningPlayerUrl(), - readLearningClassroom(requestCourseId), - fetchLearningProgress().catch(() => []), - ]) - .then(([url, nextPayload, cloudProgress]) => { - if (isCurrentClassroomRequest(request)) { - const player = parsePlayerUrl(url); - playerDocumentLoadedRef.current = false; - playerBridgeActiveRef.current = true; - setPlayerReady(false); - setLoaded(false); - setOwnedError(null); - setRuntimeNotice(null); - setSession({ - accountKey: requestAccountKey, - playerUrl: player.url, - playerOrigin: player.origin, - payload: nextPayload, - initialProgress: resolveProgress(requestAccountKey, nextPayload, cloudProgress), - }); - } - }) - .catch((cause) => { - if (isCurrentClassroomRequest(request)) { - setOwnedError({ accountKey: requestAccountKey, message: cause instanceof Error ? cause.message : '课程无法打开' }); - } - }); - }, [accountKey, beginClassroomRequest, courseId, isCurrentClassroomRequest, resolveProgress]); - - const selectModule = (moduleId: string) => { - if (!accountKey || !payload || moduleId === payload.moduleId) return; - const request = beginClassroomRequest(); - bridgeGenerationRef.current += 1; - setLoaded(false); - setOwnedError(null); - void Promise.all([ - readLearningClassroom(courseId, moduleId), - fetchLearningProgress().catch(() => []), - ]).then(([nextPayload, cloudProgress]) => { - if (!isCurrentClassroomRequest(request)) return; - setSession((current) => current?.accountKey === accountKey ? { - ...current, - payload: nextPayload, - initialProgress: resolveProgress(accountKey, nextPayload, cloudProgress), - } : current); - }).catch((cause) => { - if (isCurrentClassroomRequest(request)) { - setOwnedError({ accountKey, message: cause instanceof Error ? cause.message : '课程模块无法打开' }); - } - }); - }; - - useEffect(() => { - const onMessage = (event: MessageEvent) => { - const target = iframeRef.current?.contentWindow; - if (!target || !playerOrigin || !playerBridgeActiveRef.current - || event.source !== target || event.origin !== playerOrigin) return; - const message = record(event.data); - if (!message || typeof message.type !== 'string') return; - const bridgePayload = payload?.courseId === courseId ? payload : null; - const bridgeGeneration = bridgeGenerationRef.current; - const isCurrentPlayerBridge = () => ( - bridgeGenerationRef.current === bridgeGeneration - && playerBridgeActiveRef.current - && iframeRef.current?.contentWindow === target - ); - const postToCurrentPlayer = (data: unknown, transfer?: Transferable[]) => { - if (!isCurrentPlayerBridge()) return; - if (transfer) target.postMessage(data, playerOrigin, transfer); - else target.postMessage(data, playerOrigin); - }; - if (message.type === 'makelore:player:ready') setPlayerReady(true); - if (message.type === 'makelore:player:loaded') setLoaded(true); - const agentRequest = sanitizeAgentBridgeRequest(message.request); - if (message.type === 'makelore:agent:request' && validBridgeRequestId(message.requestId) && agentRequest && bridgePayload) { - const requestId = message.requestId; - void askLearningAgent({ - ...agentRequest, - courseId: bridgePayload.courseId, - contentHash: bridgePayload.courseContentHash, - anchor: { - ...agentRequest.anchor, - moduleId: bridgePayload.moduleId, - moduleContentHash: bridgePayload.moduleContentHash, - }, - }).then( - (result) => postToCurrentPlayer({ type: 'makelore:agent:response', requestId, ok: true, text: result.text }), - (cause) => postToCurrentPlayer({ type: 'makelore:agent:response', requestId, ok: false, error: cause instanceof Error ? cause.message : '助教回答失败' }), - ); - } - if (message.type === 'makelore:transcription:request' && validBridgeRequestId(message.requestId)) { - const speech = message; - if (!(speech.audio instanceof ArrayBuffer) || !speech.audio.byteLength || speech.audio.byteLength > 26_214_400) return; - const requestId = speech.requestId; - const fileName = typeof speech.fileName === 'string' && /^[^\\/\r\n]{1,128}$/.test(speech.fileName) ? speech.fileName : 'voice.webm'; - const mimeType = typeof speech.mimeType === 'string' && /^audio\/[A-Za-z0-9.+-]{1,100}$/.test(speech.mimeType) ? speech.mimeType : 'audio/webm'; - const language = typeof speech.language === 'string' && /^[A-Za-z0-9-]{1,32}$/.test(speech.language) ? speech.language : undefined; - void transcribeLearningSpeech({ - audio: new Uint8Array(speech.audio), - fileName, - mimeType, - language, - }).then( - (result) => postToCurrentPlayer({ type: 'makelore:transcription:response', requestId, ok: true, text: result.text }), - (cause) => postToCurrentPlayer({ type: 'makelore:transcription:response', requestId, ok: false, error: cause instanceof Error ? cause.message : '语音识别失败' }), - ); - } - if (message.type === 'makelore:runtime:request' && validBridgeRequestId(message.requestId) && bridgePayload) { - const runtime = message; - const requestId = message.requestId; - const anchor = sanitizeLearningRuntimeAnchor(record(runtime.context)?.anchor); - const reply = (bridgeEvent: LearningRuntimeBridgeEvent) => { - if (!isCurrentPlayerBridge()) return; - if (bridgeEvent.type === 'start') { - if (bridgeEvent.status === 401 || bridgeEvent.status === 403) { - setRuntimeNotice('此课堂能力需要联网并具备相应学习权益'); - } else if (bridgeEvent.status >= 400) { - setRuntimeNotice(`课堂联网能力暂时不可用(HTTP ${bridgeEvent.status})`); - } else { - setRuntimeNotice(null); - } - } - if (bridgeEvent.type === 'error') setRuntimeNotice(bridgeEvent.message); - if (bridgeEvent.type === 'chunk') { - const chunk = new Uint8Array(bridgeEvent.chunk); - postToCurrentPlayer( - { type: 'makelore:runtime:chunk', requestId: bridgeEvent.requestId, chunk }, - [chunk.buffer as ArrayBuffer], - ); - return; - } - postToCurrentPlayer({ ...bridgeEvent, type: `makelore:runtime:${bridgeEvent.type}` }); - }; - if (runtime.capability === 'parse-pdf') { - const unavailable = { - requestId, - type: 'error', - code: 'LEARNING_RUNTIME_NOT_IMPLEMENTED', - message: '课堂文件解析尚未接入,请稍后重试', - } satisfies LearningRuntimeBridgeEvent; - setRuntimeNotice(unavailable.message); - reply(unavailable); - return; - } - if (!LEARNING_RUNTIME_CAPABILITIES.has(runtime.capability as LearningRuntimeCapability) - || (runtime.method !== undefined && runtime.method !== 'POST') - || !hasBoundedJsonSize(runtime.body)) { - reply({ - requestId, - type: 'error', - code: 'LEARNING_RUNTIME_CAPABILITY_DENIED', - message: '课堂请求的联网能力不在允许范围内', - }); - return; - } - if (!anchor?.sceneId) { - reply({ - requestId, - type: 'error', - code: 'LEARNING_RUNTIME_CONTEXT_INVALID', - message: '课堂场景信息无效,请重新进入当前场景后重试', - }); - return; - } - void streamLearningRuntime({ - requestId, - courseId: bridgePayload.courseId, - contentHash: bridgePayload.courseContentHash, - capability: runtime.capability as LearningRuntimeCapability, - method: 'POST', - context: { - moduleId: bridgePayload.moduleId, - moduleContentHash: bridgePayload.moduleContentHash, - anchor: { sceneId: anchor.sceneId, ...(anchor.sceneOrder !== undefined ? { sceneOrder: anchor.sceneOrder } : {}) }, - }, - body: runtime.body ?? {}, - }, reply).catch((cause) => reply({ - requestId, - type: 'error', - code: 'LEARNING_RUNTIME_UNAVAILABLE', - message: cause instanceof Error ? cause.message : '课堂联网能力暂时不可用', - })); - } - if (message.type === 'makelore:progress' && bridgePayload) { - const progress = event.data as { sceneOrder?: number; actionIndex?: number; positionMs?: number; completed?: boolean }; - if (!Number.isInteger(progress.sceneOrder) || Number(progress.sceneOrder) < 0) return; - const snapshot: LearningProgressSnapshot = { - contentHash: bridgePayload.courseContentHash, - moduleId: bridgePayload.moduleId, - moduleContentHash: bridgePayload.moduleContentHash, - sceneOrder: Number(progress.sceneOrder), - actionIndex: Number.isInteger(progress.actionIndex) ? Number(progress.actionIndex) : null, - positionMs: Number.isInteger(progress.positionMs) ? Number(progress.positionMs) : null, - completed: Boolean(progress.completed), - }; - if (!accountKey) return; - window.localStorage.setItem(localProgressKey(accountKey, bridgePayload), JSON.stringify(snapshot)); - queueCloudProgress(bridgePayload.courseId, snapshot); - } - }; - window.addEventListener('message', onMessage); - return () => window.removeEventListener('message', onMessage); - }, [accountKey, courseId, payload, playerOrigin, queueCloudProgress]); - - useEffect(() => { - if (!playerReady || !payload || !playerOrigin) return; - iframeRef.current?.contentWindow?.postMessage({ type: 'makelore:course:load', ...payload, progress: initialProgress }, playerOrigin); - }, [initialProgress, payload, playerOrigin, playerReady]); - - return ( -
-
- - 本地互动课程 - {payload && payload.modules.length > 1 ? ( - - ) : null} -
- {runtimeNotice ? ( -
- {runtimeNotice} -
- ) : null} - {error ? ( -
-

课程无法打开

{error}

-
- ) : playerUrl ? ( - <> - {!loaded ?
正在还原 OpenMAIC 课堂
: null} -