From bdc290c2cffee6eebbc1b1a6826edad3f830572a Mon Sep 17 00:00:00 2001 From: inman Date: Fri, 4 Sep 2026 12:02:02 +0800 Subject: [PATCH 01/22] docs: record main synchronization --- .../tasks/20260903-pull-main-a9c4e2.md | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .project-docs/30-worklog/tasks/20260903-pull-main-a9c4e2.md diff --git a/.project-docs/30-worklog/tasks/20260903-pull-main-a9c4e2.md b/.project-docs/30-worklog/tasks/20260903-pull-main-a9c4e2.md new file mode 100644 index 0000000..9073151 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260903-pull-main-a9c4e2.md @@ -0,0 +1,76 @@ +# Task: Pull main from origin + +## Identity + +- Task ID: 20260903-pull-main-a9c4e2 +- Mode: Feature +- Branch: main +- Worktree: /Users/inmanx/Documents/makelore +- Base commit: 8e947b4f0e1ff0409e52a9d528ae3bb240ceb687 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Fetch the configured `origin` remote and synchronize local `main` with + `origin/main` using fast-forward-only semantics. +- Start the synchronized desktop project in development mode with the exact + pnpm version pinned by `packageManager`. +- Preserve existing project source, history, and local work without creating a + merge commit or rewriting history. + +## Intent And Constraints + +- Use the repository-configured remote and tracked default branch. +- Refuse divergence or conflicts rather than resolving them implicitly. +- Do not push, rebase, reset, stash, or change product behavior. + +## Outcome + +- Fetched `origin` successfully and pruned stale remote-tracking references. +- The initial synchronization reported `Already up to date.` at `8e947b4`. + A resumed synchronization then fetched 14 new remote commits and ran + `git pull --ff-only origin main`, fast-forwarding local `main` from + `8e947b4f0e1ff0409e52a9d528ae3bb240ceb687` to + `4d8b19eeb5e70c97d40d2db68f37ee9f425b54f0` without a merge commit or history + rewrite. +- On 2026-09-04, a further synchronization fetched five new remote commits and + fast-forwarded local `main` from + `4d8b19eeb5e70c97d40d2db68f37ee9f425b54f0` to + `403236115bf81e7617856cd8219b1be23f6abbb8` with the same fast-forward-only + policy. +- Local `main`, `origin/main`, and `origin/HEAD` now resolve to `4032361`. +- The pre-existing untracked task record was preserved, and no local product + source was authored during synchronization. +- Started `corepack pnpm run dev` with pnpm `10.33.4`. Vite is serving the + Renderer at `http://localhost:5173/`, Electron launched successfully, and the + Main-owned Host API is listening at `http://127.0.0.1:13210`. + +## Verification + +- `git rev-list --left-right --count HEAD...origin/main` returned `0 0` after + the resumed fetch and pull. +- The latest local and remote commit is `4032361` (`fix(design): show submitted + messages immediately`). +- At the `4d8b19e` checkpoint, `git log 8e947b4..4d8b19e` listed exactly 14 + newly fast-forwarded commits. +- `git log 4d8b19e..HEAD` lists exactly five commits from the 2026-09-04 + fast-forward. +- The task-aware document drift check reported `BLOCKED` because its original + `8e947b4` baseline classifies all subsequently fast-forwarded upstream files + as foreign task changes. Inspection confirmed those paths are committed + upstream history rather than local authorship, so they were not reverted. +- The development process remained alive after initial Renderer, Main, Preload, + and utility-worker compilation; the startup log reached `Makelore Application + Starting` and confirmed the Host API listener. +- No code tests were run for either resumed synchronization because each only + fast-forwarded already-authored upstream commits; repository behavior was not + modified locally. + +## Follow-ups + +- None recorded. + +## Promotion Candidates + +- None recorded. From bd0873f34823754760368d8d37703c74bf65106d Mon Sep 17 00:00:00 2001 From: inman Date: Fri, 4 Sep 2026 12:01:42 +0800 Subject: [PATCH 02/22] feat: remove Learning module --- AGENTS.md | 2 +- README.md | 9 +- ...earning-project-catalog-server-contract.md | 136 ------ electron/api/route-handlers.ts | 2 - electron/api/routes/learning.ts | 444 ------------------ electron/main/background-lifecycle.ts | 2 +- electron/main/index.ts | 2 +- .../services/learning-project-download.ts | 221 --------- shared/learning.ts | 45 -- shared/module-access.ts | 3 - src/App.tsx | 5 - src/assets/module-learning.webp | Bin 45392 -> 0 bytes src/components/layout/LearningSidebar.tsx | 37 -- src/components/layout/Sidebar.tsx | 4 - src/lib/ai-modules.ts | 17 +- src/lib/host-api.ts | 2 +- src/lib/learning.ts | 156 ------ src/pages/Learning/ProjectDetail.tsx | 165 ------- src/pages/Learning/ProjectImage.tsx | 68 --- src/pages/Learning/index.tsx | 149 ------ src/pages/ModuleSelection/index.tsx | 2 - src/stores/auth.ts | 2 +- tests/e2e/image-workspace-v2.spec.ts | 4 +- tests/e2e/main-navigation.spec.ts | 23 +- tests/e2e/plugin-marketplace.spec.ts | 4 +- tests/unit/app-module-provider-gate.test.tsx | 18 +- tests/unit/auth-routes.test.ts | 3 +- tests/unit/auth-store.test.ts | 15 +- tests/unit/background-lifecycle.test.ts | 4 +- tests/unit/host-api-dispatcher.test.ts | 15 + tests/unit/learning-client.test.ts | 89 ---- tests/unit/learning-page.test.tsx | 120 ----- tests/unit/learning-project-download.test.ts | 150 ------ tests/unit/learning-project-image.test.tsx | 30 -- tests/unit/learning-route.test.ts | 303 ------------ tests/unit/learning-sidebar.test.tsx | 33 -- tests/unit/main-layout-module-gate.test.tsx | 22 +- tests/unit/module-access.test.ts | 33 ++ tests/unit/module-navigation.test.tsx | 43 +- 39 files changed, 104 insertions(+), 2278 deletions(-) delete mode 100644 docs/learning-project-catalog-server-contract.md delete mode 100644 electron/api/routes/learning.ts delete mode 100644 electron/services/learning-project-download.ts delete mode 100644 shared/learning.ts delete mode 100644 src/assets/module-learning.webp delete mode 100644 src/components/layout/LearningSidebar.tsx delete mode 100644 src/lib/learning.ts delete mode 100644 src/pages/Learning/ProjectDetail.tsx delete mode 100644 src/pages/Learning/ProjectImage.tsx delete mode 100644 src/pages/Learning/index.tsx delete mode 100644 tests/unit/learning-client.test.ts delete mode 100644 tests/unit/learning-page.test.tsx delete mode 100644 tests/unit/learning-project-download.test.ts delete mode 100644 tests/unit/learning-project-image.test.tsx delete mode 100644 tests/unit/learning-route.test.ts delete mode 100644 tests/unit/learning-sidebar.test.tsx create mode 100644 tests/unit/module-access.test.ts diff --git a/AGENTS.md b/AGENTS.md index d2d2fae..29cb3c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Product boundary -Makelore is a cross-platform Electron application with four enabled modules: `Makelore Code|AI 编程`, `Makelore Canvas|AI 绘画`, `Makelore Learning|AI 学习`, and `Makelore Robot|AI 机器`. The repository root is the complete product source tree. It does not depend on a sibling source checkout. +Makelore is a cross-platform Electron application with three enabled modules: `Makelore Code|AI 编程`, `Makelore Canvas|AI 绘画`, and `Makelore Robot|AI 机器`. The repository root is the complete product source tree. It does not depend on a sibling source checkout. Do not restore Works gallery, asset gallery, publish/upload, or cloud-deploy workbench pages. `/deliverables` is a project output preview and remains supported. diff --git a/README.md b/README.md index 533f85f..299500d 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,13 @@ **一念成光,万物可创。** -Makelore 是一个面向软件、视觉创作、互动学习与智能机器人的 AI 桌面工作台。当前版本为 `2.0.0`,包含四个已开通产品模块。模块入口页采用统一的横向卡片视觉,工作区左上角入口点击后返回模块入口页: +Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工作台。当前版本为 `2.0.0`,包含三个已开通产品模块。模块入口页采用统一的横向卡片视觉,工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 - `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。用户通过对话或直接编辑持续完善同一设计方向,再生成图片、单参考图作品或视频;参考素材可从当前项目作品选择或从本地上传。Canvas 侧栏提供“获取灵感”,进入服务端驱动的提示词博物馆。 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 -- `Makelore Learning|AI 学习`:浏览运营精选的学习项目,阅读项目 README,并把经过完整性校验的 ZIP 源码包保存到电脑继续实践。 -应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与四模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 +应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与三个模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“小游戏”“小程序”或“自定义项目”:小游戏和小程序会创建完整的平台发布模板,项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。 @@ -29,7 +28,6 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人 - AI 绘画生成:服务端从已确认规格编译专业图片或视频指令并返回不可变 Quote;客户端只展示媒介、画幅、数量、格式、时长、警告和设计点,不展示或改写供应商 Prompt、模型、价格原子或存储地址。确认时只提交 Quote 身份。图片可使用当前 Workspace 的上传素材或生成作品,视频可绑定已审核首帧;任务与资产始终属于 Workspace。 - AI 绘画健壮性:Renderer 为每次命令生成稳定 operation id,网络结果未知时保留原命令供原样重试,不把未知写入当失败或创建第二次生成。Main 负责 Token 刷新、Agent Gateway REST 提交和有界 Run 查询,并将可恢复事件流投影为 Host API SSE;断线后按事件游标续接并重新读取权威 Workspace。Canvas 只使用 Works Square 云端 V2 契约,没有本地语义适配器或降级路径,上游不可用时明确报错。 - AI 绘画项目栏只展示 Workspace,不再在项目下创建独立设计会话。删除时必须完整输入项目名称;删除后项目、Living Form、任务、参考图和生成作品会从账户中隐藏且无法访问,不影响用户已另存到磁盘的副本。删除当前项目后自动打开最近更新的剩余项目,删除最后一个项目后进入空状态。 -- AI 学习:主区展示服务端分页项目卡片,详情页用安全 Markdown 渲染 README;原始 HTML 被禁用,Markdown 图片节点直接加载服务端校验后的无凭据 HTTPS URL,包括 SVG 和 Electron 支持的其他图片格式,不经过服务端下载、识别、转码或镜像。下载按钮打开系统保存对话框,Main 不按 `Content-Length`、声明字节数或客户端上限阻断下载,流式校验 SHA-256 与 ZIP 签名后原子保存。客户端不提供课程生成、课程播放器、本地课程库、Agent、ASR 或课堂 runtime。运营管理与接口字段见 [`docs/learning-project-catalog-server-contract.md`](docs/learning-project-catalog-server-contract.md)。 - 插件工作台:Code 侧栏只有一个“插件”入口,`/plugins` 在同一列表中投影官方目录、账号 Library、本机 Device Packages 与当前项目状态;旧 Marketplace、My Plugins、Project Plugins URL 只做确定性筛选重定向。来源身份保持 `official:`、`local:`、`retained:` 分离;“免费获取”、官方设备交付、项目启用和伙伴分配仍分别写入既有 authority,不会自动推进下一步。任一来源失败不会清空其他来源;本机 Skill/Pi 插件仍只能通过对话安装,页面只管理已安装包并明确其本机全局生效范围。 - 官方插件运行架构:Renderer 只调用 Main-owned Marketplace facade;Main 负责账号、请求 deadline、签名/摘要校验、不可变 Release、current selection 与原子回滚。Pi parent worker 使用冻结的 effective snapshot,将每个有效 Skill 与已验证 Package Store root 成对传给 resource loader、Extension Host 和 CLI;`skill_only` 不依赖运行时 Policy,也不执行分发包中的任意代码。正式激活仍等待官方 Ed25519 公钥(production key activation HOLD);生产私钥只能来自部署 secret,测试使用注入的临时密钥。 - 原生 Web Search 是冻结 selected model/provider/credential 的 parent-only 模型工具;只有 Works `model_capabilities` 明确声明支持时才出现,使用当前模型的正常计费链,不依赖 Marketplace 获取、Release、Admission、Hosted Provider 或 `agent_browser` 回退。 @@ -75,7 +73,7 @@ pnpm run package:win pnpm run package:linux ``` -各平台打包脚本会先准备目标架构所需的 Pi、Python 与 uv 运行时资源,产物写入忽略的 `release/` 目录。AI 学习不再携带独立播放器产物。正式包中的 Pi、Python 和 uv 均从安装目录解析;Agent Server 以已 staged 的 `resources/pi-runtime/package.json` 为解析锚点,并校验目标包入口仍位于该包内部。缺少本地资源或合法入口时启动和产物验证会直接失败,不会回退到应用 `node_modules`、系统 Python、npm 或 npx 下载。Git、项目编译器和用户选择的浏览器仍属于项目/系统工具,不属于内置 Pi 运行时。macOS、Windows 与 Linux 的各架构产物需要分别完成对应架构的 staging 与产物验证后再发布。 +各平台打包脚本会先准备目标架构所需的 Pi、Python 与 uv 运行时资源,产物写入忽略的 `release/` 目录。正式包中的 Pi、Python 和 uv 均从安装目录解析;Agent Server 以已 staged 的 `resources/pi-runtime/package.json` 为解析锚点,并校验目标包入口仍位于该包内部。缺少本地资源或合法入口时启动和产物验证会直接失败,不会回退到应用 `node_modules`、系统 Python、npm 或 npx 下载。Git、项目编译器和用户选择的浏览器仍属于项目/系统工具,不属于内置 Pi 运行时。macOS、Windows 与 Linux 的各架构产物需要分别完成对应架构的 staging 与产物验证后再发布。 Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:pi:real` 和 `pnpm run perf:pi:release`。这里的 `real` 表示从最终产品可执行文件启动最终 `resources/pi-runtime`,并使用受控的 Provider-shaped 回环服务验证会话、工具、中止、结算、重开、并发隔离、子 Agent 与退出;它不表示真实外部 Provider 已验证。各目标平台、证据字段、兼容边界与整版本回滚步骤见 [`docs/pi-runtime-release-runbook.md`](docs/pi-runtime-release-runbook.md)。 @@ -100,7 +98,6 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - AI 编程项目配置只以项目内 `.makelore/project.json` 为准;项目文件和会话主数据保持本地,问答观察快照按个人资料同步规则单向上行。Main 不探测、读取或迁移 `.niancode` 与 `.opencode` 项目数据。 - AI 绘画 Renderer 只调用 Main-owned Host API;Main 负责 Works Square Token 刷新、Workspace 所属的持久 Agent Session、稳定命令身份、有界 Run 查询、可恢复事件订阅与契约映射,并通过本机 Host API 的 SSE 投影同步表单、任务和资产状态。切换 Workspace 只重连对应流;注销或退出时关闭本地流,不删除服务端持久 Session。远端 Token、Provider Prompt 与存储地址不进入 Renderer。 - AI 绘画使用独立的云端 Workspace 边界,不回退到 AI 编程项目数据,也不向 Renderer 暴露 Provider、模型、Prompt、存储 URI 或远端登录 Token。 -- AI 学习只通过 Main-owned Host API 获取项目列表、README 详情和封面/历史媒体路径;Renderer 不持有 Works Token、对象存储地址、任意归档 URL 或本地文件路径。项目 ZIP 只允许同 Works origin 最多五跳重定向,重定向请求不携带 Bearer;下载结果仅向 Renderer 返回 `saved` 或 `cancelled`。README 不执行原始 HTML,仅 Markdown 图片节点可直接加载服务端校验后的无凭据 HTTPS URL。 - Prompt Museum 使用独立的 Main-owned Host API 代理;Renderer 只接收分页卡片、详情和服务端返回的图片地址,Works Square Token 只由 Main 持有。发布记录必须由服务端完成作者、来源、许可证和素材授权审核,模块不提供投稿、点赞、评论或排行榜。 - AI 编程的智能体配置属于当前项目;稳定 id 用于保持对话兼容,显示名称可以修改。AI 绘画的设计 Agent 是固定产品能力,不作为用户可增删的项目实体。 diff --git a/docs/learning-project-catalog-server-contract.md b/docs/learning-project-catalog-server-contract.md deleted file mode 100644 index 68a5a8d..0000000 --- a/docs/learning-project-catalog-server-contract.md +++ /dev/null @@ -1,136 +0,0 @@ -# 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` | - -上传中的对象不能直接进入公开目录。发布必须在一个事务/发布代际中冻结元数据、经过净化和 URL 校验的 README、封面及 ZIP 摘要;任一校验失败则整个发布失败,旧的已发布版本继续可读。下架后列表和详情立即不可见,但已有审计记录不能物理删除。 - -后台必须记录操作人、时间、发布代际、变更摘要、归档 SHA-256/字节数、README 警告和远程图片 URL 数量。客户端不提供任何运营上传或发布入口。 - -## 3. README 远程图片发布规则 - -README 中的远程图片在“发布”时由服务端解析 Markdown,并保留通过校验的原始 HTTPS URL;服务端不下载或处理图片字节。处理要求: - -1. 只接受无用户名/密码的 HTTPS URL;拒绝 `http:`、`data:`、`file:`、本地路径和协议相对地址。 -2. 只允许默认 HTTPS 端口且不允许 fragment;解析当前 DNS,任一结果属于 loopback、私网、链路本地、保留地址、云元数据或其他非公网地址时拒绝发布。 -3. 限制 README 图片总量;不请求远端响应,因此不校验重定向、响应大小、MIME、像素、实际格式或内容。SVG 及其他 Electron 可渲染格式可直接显示。 -4. 发布后的 Markdown 保留通过校验的 URL,不创建 README 图片 blob 或新 release-media 行;现有媒体路由继续用于封面和历史已镜像发布。 -5. Markdown 原始 HTML 在客户端被禁用;服务端也从发布内容中移除 HTML 并返回 warning,避免运营误判展示效果。 -6. 客户端请求会直接到第三方图片 origin;图片可用性、后续 DNS/重定向和格式支持由 origin 与 Electron 决定,origin 也会看到请求方网络信息。单图加载失败不得阻断 README 其余内容。 - -封面同样优先返回固定媒体路径。若返回 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![接线图](https://docs.example.com/wiring.svg)", - "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。新发布 README 的 HTTPS 图片不经过该接口。 -- 归档接口返回 `application/zip`、`application/x-zip-compressed` 或 `application/octet-stream`;`Content-Length` 可省略,客户端也不使用它决定是否保存。如需重定向,只能跳转到与 Works API 相同 origin 的 HTTP(S) 地址,最多 5 跳;Main 不向重定向目标转发 Bearer。 -- `archiveBytes` 是列表、详情和运营审计使用的展示元数据,不是客户端下载门禁。客户端不比较它、`Content-Length` 与实际流字节数,也不设置归档大小上限;客户端仍要求实际归档匹配 `archiveSha256` 和 ZIP 签名,验证失败时删除临时文件,不留下部分下载。 - -## 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;可记录规范化主机、URL 摘要和图片数量。 - -上线顺序:先部署数据库/对象存储、运营后台、README HTTPS URL 校验和四个公开接口,再发布包含直连图片与无下载大小校验的新客户端;随后用真实账号完成发布/下架/远程图片(含 SVG、失效 origin 和隐私提示)/ZIP 联调,并覆盖缺失或不准确 `Content-Length` 的归档响应。旧 `/api/learning/courses`、generation/progress/runtime 接口不在新客户端兼容范围内,可按服务端消费者盘点结果独立退役。 diff --git a/electron/api/route-handlers.ts b/electron/api/route-handlers.ts index 4168cbb..6bd875d 100644 --- a/electron/api/route-handlers.ts +++ b/electron/api/route-handlers.ts @@ -6,7 +6,6 @@ import { handleAppRoutes } from './routes/app'; import { handleAuthRoutes } from './routes/auth'; import { handleImageWorkspaceRoutes } from './routes/image-workspace'; import { handleImagePromptMuseumRoutes } from './routes/image-prompt-museum'; -import { handleLearningRoutes } from './routes/learning'; import { handleDataServiceRoutes } from './routes/data-service'; import { handleWorksRoutes } from './routes/works'; import { handleUserSyncRoutes } from './routes/user-sync'; @@ -44,7 +43,6 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [ handleAuthRoutes, handleImageWorkspaceRoutes, handleImagePromptMuseumRoutes, - handleLearningRoutes, handleDataServiceRoutes, handleWorksRoutes, handleAgentBrowserRoutes, diff --git a/electron/api/routes/learning.ts b/electron/api/routes/learning.ts deleted file mode 100644 index c2bcece..0000000 --- a/electron/api/routes/learning.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { app, dialog, type SaveDialogOptions } from 'electron'; -import type { IncomingMessage, ServerResponse } from 'node:http'; -import { join } from 'node:path'; -import { - 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'; - -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 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 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; -}; - -class InvalidLearningDataError extends Error {} - -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]}`, - }; - } - const download = LOCAL_DOWNLOAD_PATH_PATTERN.exec(pathname); - if (download) { - return { - kind: 'download', - projectId: download[1], - upstreamPath: `${UPSTREAM_ROOT}/${download[1]}`, - }; - } - const detail = LOCAL_DETAIL_PATH_PATTERN.exec(pathname); - return detail - ? { kind: 'detail', projectId: detail[1], upstreamPath: `${UPSTREAM_ROOT}/${detail[1]}` } - : null; -} - -function listQuery(url: URL): string { - const query = new URLSearchParams(); - 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}` : ''; -} - -function asRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) throw new InvalidLearningDataError(); - return value as Record; -} - -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 nullableString(value: unknown, maximum: number): string | null { - return value === null ? null : boundedString(value, maximum); -} - -function boundedInteger(value: unknown, minimum: number, maximum: number): number { - if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) { - throw new InvalidLearningDataError(); - } - return value as number; -} - -function isoTimestamp(value: unknown): string { - const timestamp = boundedString(value, 64); - if (!Number.isFinite(Date.parse(timestamp))) throw new InvalidLearningDataError(); - return timestamp; -} - -function mediaUrl(value: unknown): string { - const url = boundedString(value, 2048); - if (MEDIA_URL_PATTERN.test(url)) return url; - let parsed: URL; - try { - parsed = new URL(url); - } catch { - throw new InvalidLearningDataError(); - } - if (parsed.protocol !== 'https:' || parsed.username || parsed.password) throw new InvalidLearningDataError(); - return parsed.toString(); -} - -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, Number.MAX_SAFE_INTEGER), - 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 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(); - } - chunks.push(value); - } - try { - return JSON.parse(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8')) as unknown; - } catch { - throw new InvalidLearningDataError(); - } -} - -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 === 401) return { status, code: 'LEARNING_AUTH_REQUIRED', 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: safeStatus === 429 ? '请求过于频繁,请稍后再试' : '学习项目服务暂时不可用', - }; -} - -function sendSafeError(res: ServerResponse, status: number): void { - const error = safeError(status); - sendJson(res, error.status, { success: false, ...error }); -} - -function sendInvalidResponse(res: ServerResponse): void { - sendJson(res, 502, { - success: false, - status: 502, - code: 'LEARNING_INVALID_RESPONSE', - 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, - ): Promise { - const route = matchRoute(url.pathname); - if (!route) return false; - const expectedMethod = route.kind === 'download' ? 'POST' : 'GET'; - if (req.method !== expectedMethod) { - sendJson(res, 405, { - success: false, - status: 405, - code: 'LEARNING_METHOD_NOT_ALLOWED', - error: '不支持的学习项目请求', - }); - return true; - } - - try { - if (route.kind === 'download') { - const binding = getAccountBinding(); - if (!binding) { - sendSafeError(res, 401); - return true; - } - 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; - } - - 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) { - 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); - } - return true; - } - }; -} - -export const handleLearningRoutes = createLearningRouteHandler(); diff --git a/electron/main/background-lifecycle.ts b/electron/main/background-lifecycle.ts index e144fb4..36ee620 100644 --- a/electron/main/background-lifecycle.ts +++ b/electron/main/background-lifecycle.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events'; -export type DesktopModule = 'programming' | 'painting' | 'learning' | 'robot' | 'other'; +export type DesktopModule = 'programming' | 'painting' | 'robot' | 'other'; export type DesktopActivity = { visible: boolean; diff --git a/electron/main/index.ts b/electron/main/index.ts index 2100f5c..0d24883 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -663,7 +663,7 @@ async function initialize(): Promise { }); // Coding workers are started on demand. Keeping them cold for - // Canvas/Learning/Robot avoids a resident child process on every launch. + // Canvas/Robot avoids a resident child process on every launch. void initializeBackgroundServices().catch((error) => { logger.warn('Deferred background initialization failed:', error); }); diff --git a/electron/services/learning-project-download.ts b/electron/services/learning-project-download.ts deleted file mode 100644 index 0b30d68..0000000 --- a/electron/services/learning-project-download.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { createHash, randomUUID } from 'node:crypto'; -import { open, rename, rm } from 'node:fs/promises'; -import { basename, dirname, extname, join } from 'node:path'; -import 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) - || !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 handle = await open(input.temporaryPath, 'wx'); - const hash = createHash('sha256'); - const signature: number[] = []; - const reader = input.response.body.getReader(); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - assertCurrentAccount(input.binding, input.isCurrentAccountBinding); - 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 (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/shared/learning.ts b/shared/learning.ts deleted file mode 100644 index 24e1f0b..0000000 --- a/shared/learning.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** Stable project-catalog boundary between Makelore and Works Square. */ - -export const LEARNING_API_PATH = '/api/works/learning'; - -export const LEARNING_MEDIA_MAX_BYTES = 10 * 1024 * 1024; - -export type LearningProjectImage = { - url: string; - alt: string; - width?: number; - height?: number; -}; - -export type LearningProjectSummary = { - id: string; - name: string; - summary: string; - cover: LearningProjectImage; - tags: string[]; - version: string | null; - archiveBytes: number; - publishedAt: string; - updatedAt: string; -}; - -export type LearningProjectDetail = LearningProjectSummary & { - readmeMarkdown: string; - archiveFileName: string; - archiveSha256: string; -}; - -export type LearningProjectPage = { - items: LearningProjectSummary[]; - nextCursor: string | null; - total?: number; -}; - -export type LearningProjectListQuery = { - cursor?: string; - limit?: number; -}; - -export type LearningProjectDownloadResult = { - status: 'saved' | 'cancelled'; -}; diff --git a/shared/module-access.ts b/shared/module-access.ts index dcc77ad..412a954 100644 --- a/shared/module-access.ts +++ b/shared/module-access.ts @@ -1,7 +1,6 @@ export const MODULE_ACCESS_KEYS = [ 'programming', 'design', - 'learning', 'robot', ] as const; @@ -12,7 +11,6 @@ export type ModuleAccess = Record; export const DEFAULT_MODULE_ACCESS: ModuleAccess = { programming: true, design: true, - learning: true, robot: true, }; @@ -24,7 +22,6 @@ export function normalizeModuleAccess(value: unknown): ModuleAccess { return { programming: typeof record.programming === 'boolean' ? record.programming : true, design: typeof record.design === 'boolean' ? record.design : true, - learning: typeof record.learning === 'boolean' ? record.learning : true, robot: typeof record.robot === 'boolean' ? record.robot : true, }; } diff --git a/src/App.tsx b/src/App.tsx index c97a8d8..f1cd4a1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,8 +42,6 @@ const Settings = lazy(() => import('./pages/Settings').then(({ Settings: compone const Setup = lazy(() => import('./pages/Setup').then(({ Setup: component }) => ({ default: 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 LearningProjectDetail = lazy(() => import('./pages/Learning/ProjectDetail').then(({ LearningProjectDetail: component }) => ({ default: component }))); /** * Error Boundary to catch and display React rendering errors @@ -129,7 +127,6 @@ function moduleForPath(pathname: string): DesktopActivityModule | null { const module = getGuardedAiModuleForPath(pathname); if (module === 'programming') return 'programming'; if (module === 'painting') return 'painting'; - if (module === 'learning') return 'learning'; if (module === 'robot') return 'robot'; return null; } @@ -457,8 +454,6 @@ function App() { } /> } /> } /> - } /> - } /> } /> } /> } /> diff --git a/src/assets/module-learning.webp b/src/assets/module-learning.webp deleted file mode 100644 index 0ecd91b7de085dadd8556ec5b390c770537af30a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45392 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 Qmu>ZW{FA|^slosN0E~N%iU0rr diff --git a/src/components/layout/LearningSidebar.tsx b/src/components/layout/LearningSidebar.tsx deleted file mode 100644 index 506ff71..0000000 --- a/src/components/layout/LearningSidebar.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { FolderKanban } from 'lucide-react'; -import { useLocation, useNavigate } from 'react-router-dom'; -import { cn } from '@/lib/utils'; - -type LearningSidebarProps = { - sidebarCollapsed: boolean; -}; - -export function LearningSidebar({ sidebarCollapsed }: LearningSidebarProps) { - const navigate = useNavigate(); - const location = useLocation(); - const active = location.pathname === '/learning' || location.pathname.startsWith('/learning/project/'); - - return ( - - ); -} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index b0e8f44..009aebb 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -40,7 +40,6 @@ import { useCurrentUserProfile } from '@/hooks/use-current-user-profile'; import type { SidebarPeekSource } from './sidebar-peek'; import { ModuleSwitcher } from './ModuleSwitcher'; import { ImageWorkspaceSidebar } from './ImageWorkspaceSidebar'; -import { LearningSidebar } from './LearningSidebar'; import { SidebarUpdateButton } from './SidebarUpdateButton'; import { UserAvatar } from '@/components/profile/UserAvatar'; import { getAccountInitial } from '@/components/profile/user-avatar-utils'; @@ -185,7 +184,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi const activeModule = getAiModuleForPath(location.pathname); const isProgrammingModule = activeModule === 'programming'; const isPaintingModule = activeModule === 'painting'; - const isLearningModule = activeModule === 'learning'; const isRobotModule = activeModule === 'robot'; const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/'); const projectConfigPath = '/project-config'; @@ -672,8 +670,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi ) : null} - ) : isLearningModule ? ( - ) : (
- -
- - - ); - } - - return ( -
-
- - -
- -
- -
-
- {project.tags.map((tag) => ( - {tag} - ))} -
-

{project.name}

-

{project.summary}

-
- ZIP · {formatBytes(project.archiveBytes)} - {project.version ? `版本 ${project.version}` : '最新版'} - 更新于 {new Date(project.updatedAt).toLocaleDateString('zh-CN')} -
-
-
- -
-

项目说明

-
-

{children}

, - h2: ({ children }) =>

{children}

, - h3: ({ children }) =>

{children}

, - p: ({ children }) =>

{children}

, - a: ({ href, children }) => isHttpsLink(href) ? ( -
{children} - ) : {children}, - img: ({ src, alt }) => typeof src === 'string' ? ( - - ) : null, - pre: ({ children }) =>
{children}
, - code: ({ children, className }) => {children}, - blockquote: ({ children }) =>
{children}
, - table: ({ children }) =>
{children}
, - }} - > - {project.readmeMarkdown} - -
-
-
- ); -} diff --git a/src/pages/Learning/ProjectImage.tsx b/src/pages/Learning/ProjectImage.tsx deleted file mode 100644 index 8f40b4e..0000000 --- a/src/pages/Learning/ProjectImage.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useEffect, useState } from 'react'; -import { ImageOff, Loader2 } from 'lucide-react'; -import { - fetchLearningProjectMedia, - isLearningProjectMediaUrl, - isSafeLearningImageUrl, -} from '@/lib/learning'; -import { cn } from '@/lib/utils'; - -type ProjectImageProps = { - src: string; - alt: string; - className?: string; - allowHttps?: boolean; -}; - -export function ProjectImage({ src, alt, className, allowHttps = false }: ProjectImageProps) { - const controlledMedia = isLearningProjectMediaUrl(src); - const directSource = allowHttps && isSafeLearningImageUrl(src) && !controlledMedia ? src : null; - const [failedSource, setFailedSource] = useState(null); - const [state, setState] = useState<{ input: string; source: string; status: 'ready' | 'error' }>({ - input: '', - source: '', - status: 'error', - }); - - useEffect(() => { - if (!controlledMedia) return undefined; - let cancelled = false; - void fetchLearningProjectMedia(src).then((source) => { - if (!cancelled) setState({ input: src, source, status: 'ready' }); - }).catch(() => { - if (!cancelled) setState({ input: src, source: '', status: 'error' }); - }); - return () => { cancelled = true; }; - }, [controlledMedia, src]); - - if (failedSource === src || (!controlledMedia && !directSource)) { - return ( - - - - ); - } - if (controlledMedia && state.input !== src) { - return ( - - - - ); - } - if (controlledMedia && state.status === 'error') { - return ( - - - - ); - } - return ( - {alt} setFailedSource(src)} - /> - ); -} diff --git a/src/pages/Learning/index.tsx b/src/pages/Learning/index.tsx deleted file mode 100644 index d08f514..0000000 --- a/src/pages/Learning/index.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { ArrowRight, FolderArchive, Loader2, RefreshCw } from 'lucide-react'; -import { useNavigate } from 'react-router-dom'; -import { Button } from '@/components/ui/button'; -import { fetchLearningProjects } from '@/lib/learning'; -import type { LearningProjectSummary } from '../../../shared/learning'; -import { ProjectImage } from './ProjectImage'; - -function formatBytes(bytes: number): string { - if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; - if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} MB`; - return `${Math.max(1, Math.round(bytes / 1024))} KB`; -} - -function mergeProjects( - current: LearningProjectSummary[], - incoming: LearningProjectSummary[], -): LearningProjectSummary[] { - const projects = new Map(current.map((project) => [project.id, project])); - for (const project of incoming) projects.set(project.id, project); - return [...projects.values()]; -} - -export function Learning() { - const navigate = useNavigate(); - const [projects, setProjects] = useState([]); - const [nextCursor, setNextCursor] = useState(null); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [error, setError] = useState(null); - - const loadProjects = useCallback(async (cursor?: string) => { - const append = Boolean(cursor); - if (append) setLoadingMore(true); - else setLoading(true); - setError(null); - try { - const page = await fetchLearningProjects({ cursor, limit: 24 }); - setProjects((current) => append ? mergeProjects(current, page.items) : page.items); - setNextCursor(page.nextCursor); - } catch (cause) { - setError(cause instanceof Error ? cause.message : '学习项目暂时无法加载'); - } finally { - if (append) setLoadingMore(false); - else setLoading(false); - } - }, []); - - useEffect(() => { - void loadProjects(); - }, [loadProjects]); - - return ( -
-
-
-

动手学习

-

学习项目

-

- 选择一个感兴趣的项目,先阅读完整介绍,再把源码压缩包保存到电脑继续实践。 -

-
- -
- - {loading ? ( -
- 正在读取项目 -
- ) : error && projects.length === 0 ? ( -
-

项目列表加载失败

-

{error}

- -
- ) : projects.length === 0 ? ( -
- -

暂时还没有已发布项目

-

运营发布后,项目会直接出现在这里。

-
- ) : ( - <> - {error ?

{error}

: null} -
- {projects.map((project) => ( -
- -
- ))} -
- {nextCursor ? ( -
- -
- ) : null} - - )} -
- ); -} diff --git a/src/pages/ModuleSelection/index.tsx b/src/pages/ModuleSelection/index.tsx index 8cd05bb..efe15d8 100644 --- a/src/pages/ModuleSelection/index.tsx +++ b/src/pages/ModuleSelection/index.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import moduleCanvasImage from '@/assets/module-canvas-neon.webp'; import moduleGameImage from '@/assets/module-game.webp'; -import moduleLearningImage from '@/assets/module-learning.webp'; import moduleRobotImage from '@/assets/module-robot.webp'; import logoWordmarkSource from '@/assets/makelore-wordmark-source.png'; import { UserProfileDialog } from '@/components/profile/UserProfileDialog'; @@ -16,7 +15,6 @@ import { getUserProfileDisplayName } from '@/stores/user-profile'; const moduleSelectionContent: Record = { programming: { label: 'Code 编程', image: moduleGameImage, imageAlt: '游戏创作工作台' }, painting: { label: 'Canvas 设计', image: moduleCanvasImage, imageAlt: '数位板设计创作' }, - learning: { label: 'Learning 学习', image: moduleLearningImage, imageAlt: '数学科技宇宙' }, robot: { label: 'Robot 机器', image: moduleRobotImage, imageAlt: '青少年管理机器人硬件与设备绑定' }, }; diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 5855cf5..48be8dc 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -785,7 +785,7 @@ export const useAuthStore = create()( }), { name: 'niancode-auth', - version: 3, + version: 4, migrate: (persistedState: unknown) => { const state = persistedState && typeof persistedState === 'object' ? persistedState as Record diff --git a/tests/e2e/image-workspace-v2.spec.ts b/tests/e2e/image-workspace-v2.spec.ts index 4bd5277..0ba93e0 100644 --- a/tests/e2e/image-workspace-v2.spec.ts +++ b/tests/e2e/image-workspace-v2.spec.ts @@ -231,7 +231,7 @@ test.describe('AI Design V2 workspace', () => { e2eGlobal.__designE2eAuthReady = true; return respond({ success: true, - moduleAccess: { programming: true, design: true, learning: true, robot: true }, + moduleAccess: { programming: true, design: true, robot: true }, }); } if (path === '/api/works/user/agent-profile') { @@ -389,7 +389,7 @@ test.describe('AI Design V2 workspace', () => { deptId: null, authorities: [], }, - moduleAccess: { programming: true, design: true, learning: true, robot: true }, + moduleAccess: { programming: true, design: true, robot: true }, }, version: 2, })); diff --git a/tests/e2e/main-navigation.spec.ts b/tests/e2e/main-navigation.spec.ts index a83fc91..0a9b53e 100644 --- a/tests/e2e/main-navigation.spec.ts +++ b/tests/e2e/main-navigation.spec.ts @@ -1,7 +1,7 @@ import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; test.describe('Makelore module navigation without setup flow', () => { - test('keeps the four module entries in a proportional vertical stack while resizing the window', async ({ launchElectronApp }) => { + test('keeps the three module entries in a proportional vertical stack while resizing the window', async ({ launchElectronApp }) => { const app = await launchElectronApp({ skipSetup: true }); try { @@ -23,7 +23,7 @@ test.describe('Makelore module navigation without setup flow', () => { })); const initialMetrics = await readCardMetrics(); - expect(initialMetrics).toHaveLength(4); + expect(initialMetrics).toHaveLength(3); await expect(moduleCards.first().locator('.module-option-card-arrow')).toHaveCount(0); await expect(moduleCards.first().locator('.module-option-artwork')).toHaveCount(0); await expect.poll(async () => await moduleCards.first().evaluate((element) => { @@ -75,7 +75,7 @@ test.describe('Makelore module navigation without setup flow', () => { await expect.poll(async () => (await readCardMetrics())[0]?.width ?? 0).toBeLessThan(initialWidth - 1); const resizedMetrics = await readCardMetrics(); - expect(resizedMetrics).toHaveLength(4); + expect(resizedMetrics).toHaveLength(3); expect(Math.max(...resizedMetrics.map((card) => card.left)) - Math.min(...resizedMetrics.map((card) => card.left))).toBeLessThan(1); expect(resizedMetrics.map((card) => card.top)).toEqual( [...resizedMetrics].sort((top, bottom) => top.top - bottom.top).map((card) => card.top), @@ -108,23 +108,18 @@ test.describe('Makelore module navigation without setup flow', () => { } }); - test('opens Learning from the module chooser and returns through the module switcher', async ({ launchElectronApp }) => { + test('omits Learning from the module chooser and redirects its retired route', async ({ launchElectronApp }) => { const app = await launchElectronApp({ skipSetup: true }); try { const page = await getStableWindow(app); await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); - const learningOption = page.getByTestId('ai-module-option-learning'); - await expect(learningOption).toBeVisible(); - await expect(learningOption).toBeEnabled(); - await learningOption.click(); - - await expect(page).toHaveURL(/\/learning$/); - await expect(page.getByTestId('learning-home')).toBeVisible(); - await expect(page.getByRole('heading', { name: '学习项目' })).toBeVisible(); - - await page.getByTestId('sidebar-module-switcher-trigger').click(); + await expect(page.locator('[data-testid^="ai-module-option-"]')).toHaveCount(3); + await expect(page.getByTestId('ai-module-option-learning')).toHaveCount(0); + await page.evaluate(() => { + window.location.hash = '#/learning'; + }); await expect(page).toHaveURL(/\/module-select$/); await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); } finally { diff --git a/tests/e2e/plugin-marketplace.spec.ts b/tests/e2e/plugin-marketplace.spec.ts index dc35059..4d665fe 100644 --- a/tests/e2e/plugin-marketplace.spec.ts +++ b/tests/e2e/plugin-marketplace.spec.ts @@ -47,7 +47,7 @@ test.describe('Unified plugin workspace', () => { user: { username: 'plugins-e2e', userId: 'plugins-e2e-user', tenantId: null, deptId: null, authorities: [], }, - moduleAccess: { programming: true, design: true, learning: true, robot: true }, + moduleAccess: { programming: true, design: true, robot: true }, }); if (requestPath === '/api/works/user/agent-profile') return result({ success: true, @@ -239,7 +239,7 @@ test.describe('Unified plugin workspace', () => { user: { username: 'plugins-e2e', userId: 'plugins-e2e-user', tenantId: null, deptId: null, authorities: [], }, - moduleAccess: { programming: true, design: true, learning: true, robot: true }, + moduleAccess: { programming: true, design: true, robot: true }, }); if (requestPath === '/api/works/user/agent-profile') return result({ success: true, diff --git a/tests/unit/app-module-provider-gate.test.tsx b/tests/unit/app-module-provider-gate.test.tsx index 24684c1..edc7385 100644 --- a/tests/unit/app-module-provider-gate.test.tsx +++ b/tests/unit/app-module-provider-gate.test.tsx @@ -63,7 +63,6 @@ describe('App programming provider initialization gate', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, init: vi.fn(), @@ -116,7 +115,6 @@ describe('App programming provider initialization gate', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, }); @@ -131,7 +129,6 @@ describe('App programming provider initialization gate', () => { moduleAccess: { programming: false, design: true, - learning: true, robot: true, }, }); @@ -147,8 +144,6 @@ describe('App programming provider initialization gate', () => { ['/chat', 'programming'], ['/image-canvas', 'design'], ['/image-prompts/example', 'design'], - ['/learning', 'learning'], - ['/learning/project/project-1', 'learning'], ['/ai-hardware', 'robot'], ['/ai-hardware/device-1', 'robot'], ] as const)('redirects disabled %s routes before mounting their module', async (pathname, accessKey) => { @@ -156,7 +151,6 @@ describe('App programming provider initialization gate', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, [accessKey]: false, }, @@ -171,12 +165,22 @@ describe('App programming provider initialization gate', () => { } }); + it.each(['/learning', '/learning/project/project-1'])( + 'redirects retired Learning route %s to the module chooser', + async (pathname) => { + await renderAt(pathname); + + expect(await screen.findByText('Module chooser')).toBeInTheDocument(); + expect(screen.queryByTestId('main-layout')).not.toBeInTheDocument(); + expect(initProviders).not.toHaveBeenCalled(); + }, + ); + it('keeps global settings available when Programming is disabled', async () => { useAuthStore.setState({ moduleAccess: { programming: false, design: true, - learning: true, robot: true, }, }); diff --git a/tests/unit/auth-routes.test.ts b/tests/unit/auth-routes.test.ts index 6636626..650793a 100644 --- a/tests/unit/auth-routes.test.ts +++ b/tests/unit/auth-routes.test.ts @@ -115,10 +115,10 @@ describe('auth host api routes', () => { moduleAccess: { programming: false, design: true, - learning: false, robot: true, }, }); + expect(response.json()).not.toHaveProperty('moduleAccess.learning'); expect(JSON.stringify(response.json())).not.toContain('must-not-reach-renderer'); expect(fetchMock).toHaveBeenCalledWith( 'https://square.nianxx.cn/api/auth/me', @@ -161,7 +161,6 @@ describe('auth host api routes', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, }); diff --git a/tests/unit/auth-store.test.ts b/tests/unit/auth-store.test.ts index 2e5e24a..edfdc10 100644 --- a/tests/unit/auth-store.test.ts +++ b/tests/unit/auth-store.test.ts @@ -24,7 +24,6 @@ function resetAuthStore() { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, }); @@ -88,7 +87,6 @@ describe('auth store', () => { moduleAccess: { programming: true, design: false, - learning: true, robot: false, }, }); @@ -129,7 +127,6 @@ describe('auth store', () => { expect(state.moduleAccess).toEqual({ programming: true, design: false, - learning: true, robot: false, }); expect(window.localStorage.getItem('niancode-auth')).not.toContain( @@ -158,7 +155,7 @@ describe('auth store', () => { }) .mockResolvedValueOnce({ success: true, - moduleAccess: { learning: false }, + moduleAccess: { robot: false }, }); await useAuthStore.getState().loginWithMobile({ @@ -179,8 +176,7 @@ describe('auth store', () => { moduleAccess: { programming: true, design: true, - learning: false, - robot: true, + robot: false, }, }); }); @@ -217,7 +213,6 @@ describe('auth store', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, }); @@ -261,7 +256,6 @@ describe('auth store', () => { expect(useAuthStore.getState().moduleAccess).toEqual({ programming: true, design: false, - learning: true, robot: true, }); }); @@ -315,7 +309,6 @@ describe('auth store', () => { moduleAccess: { programming: true, design: false, - learning: true, robot: true, }, }); @@ -422,7 +415,6 @@ describe('auth store', () => { moduleAccess: { programming: true, design: false, - learning: true, robot: true, }, user: { @@ -445,7 +437,6 @@ describe('auth store', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, }); @@ -488,7 +479,6 @@ describe('auth store', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, }); @@ -952,7 +942,6 @@ describe('auth store', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, }); diff --git a/tests/unit/background-lifecycle.test.ts b/tests/unit/background-lifecycle.test.ts index aebc5ef..cffcd90 100644 --- a/tests/unit/background-lifecycle.test.ts +++ b/tests/unit/background-lifecycle.test.ts @@ -64,7 +64,7 @@ describe('BackgroundLifecycleController', () => { onStopRuntime, }); - controller.setActivity({ visible: false, module: 'learning' }); + controller.setActivity({ visible: false, module: 'painting' }); await vi.advanceTimersByTimeAsync(60_000); expect(onSleep).toHaveBeenCalledTimes(1); @@ -90,7 +90,7 @@ describe('BackgroundLifecycleController', () => { onStopRuntime, }); - controller.setActivity({ visible: false, module: 'learning' }); + controller.setActivity({ visible: false, module: 'robot' }); await vi.advanceTimersByTimeAsync(30_000); controller.setActivity({ visible: true, module: 'programming' }); await vi.advanceTimersByTimeAsync(90_000); diff --git a/tests/unit/host-api-dispatcher.test.ts b/tests/unit/host-api-dispatcher.test.ts index ac30aa4..8e63fc8 100644 --- a/tests/unit/host-api-dispatcher.test.ts +++ b/tests/unit/host-api-dispatcher.test.ts @@ -29,4 +29,19 @@ describe('Host API transport selection', () => { expect(data).toMatchObject({ status: 200, ok: true }); expect(data.json).toMatchObject({ runtime: 'pi', diagnostics: { revision: 3 } }); }); + + it('leaves the retired Learning Host API unregistered', async () => { + const data = await dispatchHostApiRequest({} as never, { + path: '/api/works/learning/projects', + }); + + expect(data).toMatchObject({ + status: 404, + ok: false, + json: { + success: false, + error: 'No route for GET /api/works/learning/projects', + }, + }); + }); }); diff --git a/tests/unit/learning-client.test.ts b/tests/unit/learning-client.test.ts deleted file mode 100644 index 767bef2..0000000 --- a/tests/unit/learning-client.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - downloadLearningProject, - fetchLearningProject, - fetchLearningProjectMedia, - fetchLearningProjects, - openLearningExternalLink, -} from '@/lib/learning'; - -const hostApiFetchMock = vi.hoisted(() => vi.fn()); -const invokeIpcMock = vi.hoisted(() => vi.fn()); - -vi.mock('@/lib/host-api', () => ({ - hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), -})); - -vi.mock('@/lib/api-client', () => ({ - invokeIpc: (...args: unknown[]) => invokeIpcMock(...args), -})); - -describe('Learning project renderer client', () => { - beforeEach(() => { - hostApiFetchMock.mockReset(); - invokeIpcMock.mockReset(); - }); - - it('reads paged projects and project detail through the Host API', async () => { - const page = { items: [], nextCursor: 'next' }; - const detail = { id: 'project-1', name: '机械臂' }; - hostApiFetchMock - .mockResolvedValueOnce({ success: true, data: page }) - .mockResolvedValueOnce({ success: true, data: detail }); - - await expect(fetchLearningProjects({ cursor: 'cursor one', limit: 24 })).resolves.toBe(page); - await expect(fetchLearningProject('project/one')).resolves.toBe(detail); - - expect(hostApiFetchMock).toHaveBeenNthCalledWith( - 1, - '/api/works/learning/projects?cursor=cursor+one&limit=24', - undefined, - ); - expect(hostApiFetchMock).toHaveBeenNthCalledWith( - 2, - '/api/works/learning/projects/project%2Fone', - undefined, - ); - }); - - it('starts the native Main-owned download through a fixed Host route', async () => { - hostApiFetchMock.mockResolvedValue({ success: true, data: { status: 'saved' } }); - - await expect(downloadLearningProject('project-1')).resolves.toEqual({ status: 'saved' }); - - expect(hostApiFetchMock).toHaveBeenCalledWith( - '/api/works/learning/projects/project-1/download', - { method: 'POST' }, - ); - expect(invokeIpcMock).not.toHaveBeenCalled(); - }); - - it('accepts only fixed project media paths and validated raster data', async () => { - hostApiFetchMock.mockResolvedValue({ mimeType: 'image/png', dataBase64: 'AQID' }); - - await expect(fetchLearningProjectMedia( - '/api/learning/projects/project-1/media/readme-1', - )).resolves.toBe('data:image/png;base64,AQID'); - expect(hostApiFetchMock).toHaveBeenCalledWith( - '/api/works/learning/projects/project-1/media/readme-1', - ); - - await expect(fetchLearningProjectMedia('https://tracker.example/image.png')).rejects.toMatchObject({ - code: 'LEARNING_INVALID_MEDIA_URL', - }); - hostApiFetchMock.mockResolvedValueOnce({ mimeType: 'image/svg+xml', dataBase64: 'AQID' }); - await expect(fetchLearningProjectMedia( - '/api/learning/projects/project-1/media/readme-2', - )).rejects.toMatchObject({ code: 'LEARNING_INVALID_MEDIA_RESPONSE' }); - }); - - it('opens only credential-free HTTPS README links through the IPC facade', async () => { - invokeIpcMock.mockResolvedValue(undefined); - - await openLearningExternalLink('https://example.com/guide'); - expect(invokeIpcMock).toHaveBeenCalledWith('shell:openExternal', 'https://example.com/guide'); - await expect(openLearningExternalLink('http://example.com')).rejects.toMatchObject({ - code: 'LEARNING_INVALID_LINK', - }); - }); -}); diff --git a/tests/unit/learning-page.test.tsx b/tests/unit/learning-page.test.tsx deleted file mode 100644 index 57f7ce3..0000000 --- a/tests/unit/learning-page.test.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; -import { Learning } from '@/pages/Learning'; -import { LearningProjectDetail } from '@/pages/Learning/ProjectDetail'; - -const fetchLearningProjectsMock = vi.hoisted(() => vi.fn()); -const fetchLearningProjectMock = vi.hoisted(() => vi.fn()); -const downloadLearningProjectMock = vi.hoisted(() => vi.fn()); -const openLearningExternalLinkMock = vi.hoisted(() => vi.fn()); - -vi.mock('@/lib/learning', () => ({ - fetchLearningProjects: fetchLearningProjectsMock, - fetchLearningProject: fetchLearningProjectMock, - downloadLearningProject: downloadLearningProjectMock, - openLearningExternalLink: openLearningExternalLinkMock, -})); - -vi.mock('@/pages/Learning/ProjectImage', () => ({ - ProjectImage: ({ src, alt, allowHttps }: { src: string; alt: string; allowHttps?: boolean }) => ( - {alt} - ), -})); - -const project = { - id: 'robot-arm', - name: '桌面机械臂', - summary: '从零搭建一个可以抓取积木的桌面机械臂。', - cover: { - url: '/api/learning/projects/robot-arm/media/cover', - alt: '桌面机械臂封面', - }, - tags: ['机器人', 'Python'], - version: '1.2.0', - archiveBytes: 12 * 1024 * 1024, - publishedAt: '2026-08-01T00:00:00Z', - updatedAt: '2026-08-18T00:00:00Z', -}; - -function LocationProbe() { - const location = useLocation(); - return {location.pathname}; -} - -describe('Learning project pages', () => { - beforeEach(() => { - fetchLearningProjectsMock.mockReset(); - fetchLearningProjectMock.mockReset(); - downloadLearningProjectMock.mockReset(); - openLearningExternalLinkMock.mockReset(); - openLearningExternalLinkMock.mockResolvedValue(undefined); - }); - - it('renders project cards and opens a project detail route', async () => { - fetchLearningProjectsMock.mockResolvedValue({ items: [project], nextCursor: null }); - render( - - } /> - , - ); - - expect(await screen.findByTestId('learning-project-robot-arm')).toHaveTextContent('桌面机械臂'); - expect(screen.getByAltText('桌面机械臂封面')).toHaveAttribute('src', project.cover.url); - fireEvent.click(screen.getByRole('button', { name: '查看项目:桌面机械臂' })); - expect(screen.getByTestId('location-path')).toHaveTextContent('/learning/project/robot-arm'); - }); - - it('loads more projects with the opaque cursor', async () => { - fetchLearningProjectsMock - .mockResolvedValueOnce({ items: [project], nextCursor: 'cursor-2' }) - .mockResolvedValueOnce({ items: [{ ...project, id: 'robot-car', name: '智能小车' }], nextCursor: null }); - render(); - - fireEvent.click(await screen.findByRole('button', { name: '加载更多' })); - expect(await screen.findByText('智能小车')).toBeInTheDocument(); - expect(fetchLearningProjectsMock).toHaveBeenNthCalledWith(2, { cursor: 'cursor-2', limit: 24 }); - }); - - it('renders Markdown without raw HTML and allows direct HTTPS README images', async () => { - fetchLearningProjectMock.mockResolvedValue({ - ...project, - archiveFileName: 'robot-arm.zip', - archiveSha256: 'a'.repeat(64), - readmeMarkdown: '# 开始搭建\n\n![接线图](https://images.example.com/wiring.svg)\n\n\n\n[参考资料](https://example.com/guide)', - }); - render( - - } /> - , - ); - - expect(await screen.findByRole('heading', { name: '开始搭建' })).toBeInTheDocument(); - expect(screen.getByAltText('接线图')).toHaveAttribute( - 'src', - 'https://images.example.com/wiring.svg', - ); - expect(screen.getByAltText('接线图')).toHaveAttribute('data-allow-https', 'true'); - expect(document.querySelector('script')).toBeNull(); - fireEvent.click(screen.getByRole('link', { name: '参考资料' })); - expect(openLearningExternalLinkMock).toHaveBeenCalledWith('https://example.com/guide'); - }); - - it('downloads only after the detail page action', async () => { - fetchLearningProjectMock.mockResolvedValue({ - ...project, - archiveFileName: 'robot-arm.zip', - archiveSha256: 'a'.repeat(64), - readmeMarkdown: '# 项目介绍', - }); - downloadLearningProjectMock.mockResolvedValue({ status: 'saved' }); - render( - - } /> - , - ); - - fireEvent.click(await screen.findByRole('button', { name: '下载项目' })); - await waitFor(() => expect(downloadLearningProjectMock).toHaveBeenCalledWith('robot-arm')); - }); -}); diff --git a/tests/unit/learning-project-download.test.ts b/tests/unit/learning-project-download.test.ts deleted file mode 100644 index 7745e41..0000000 --- a/tests/unit/learning-project-download.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { createHash } from 'node:crypto'; -import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - saveLearningProjectArchive, - safeLearningProjectArchiveFileName, -} from '@electron/services/learning-project-download'; - -const archive = Buffer.from([0x50, 0x4b, 0x03, 0x04, 1, 2, 3, 4]); -const archiveSha256 = createHash('sha256').update(archive).digest('hex'); -const binding = { accountKey: 'a'.repeat(64), epoch: 1 }; -let root = ''; - -function project(overrides: Record = {}) { - return { - id: 'project-1', - name: '机械臂', - summary: '项目介绍', - cover: { url: '/api/learning/projects/project-1/media/cover', alt: '封面' }, - tags: [], - version: null, - archiveBytes: archive.length, - publishedAt: '2026-08-01T00:00:00Z', - updatedAt: '2026-08-01T00:00:00Z', - readmeMarkdown: '# 机械臂', - archiveFileName: 'project.zip', - archiveSha256, - ...overrides, - } as never; -} - -describe('Learning project verified download', () => { - beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'makelore-learning-project-')); - }); - - afterEach(async () => { - await rm(root, { recursive: true, force: true }); - }); - - it('streams, verifies and atomically saves a ZIP without returning its path', async () => { - const fetchImpl = vi.fn().mockResolvedValue(new Response(archive, { - status: 200, - headers: { 'Content-Type': 'application/zip', 'Content-Length': String(archive.length) }, - })); - const destinationPath = join(root, '机械臂.zip'); - - await saveLearningProjectArchive({ - project: project(), - destinationPath, - binding, - fetchImpl, - getAccessToken: vi.fn().mockResolvedValue('works-token'), - isCurrentAccountBinding: () => true, - apiBaseUrl: 'https://square.example', - }); - - await expect(readFile(destinationPath)).resolves.toEqual(archive); - expect(fetchImpl).toHaveBeenCalledWith( - 'https://square.example/api/learning/projects/project-1/archive', - expect.objectContaining({ - headers: { Accept: 'application/zip', Authorization: 'Bearer works-token' }, - redirect: 'manual', - }), - ); - }); - - it('downloads without comparing metadata or transport-reported archive sizes', async () => { - const fetchImpl = vi.fn().mockResolvedValue(new Response(archive, { - status: 200, - headers: { 'Content-Type': 'application/zip', 'Content-Length': String(archive.length + 100) }, - })); - const destinationPath = join(root, 'large-project.zip'); - - await saveLearningProjectArchive({ - project: project({ archiveBytes: 512 * 1024 * 1024 + 1 }), - destinationPath, - binding, - fetchImpl, - getAccessToken: vi.fn().mockResolvedValue('works-token'), - isCurrentAccountBinding: () => true, - apiBaseUrl: 'https://square.example', - }); - - await expect(readFile(destinationPath)).resolves.toEqual(archive); - }); - - it('downloads when the archive response omits Content-Length', async () => { - const fetchImpl = vi.fn().mockResolvedValue(new Response(archive, { - status: 200, - headers: { 'Content-Type': 'application/zip' }, - })); - const destinationPath = join(root, 'unknown-size-project.zip'); - - await saveLearningProjectArchive({ - project: project({ archiveBytes: 1 }), - destinationPath, - binding, - fetchImpl, - getAccessToken: vi.fn().mockResolvedValue('works-token'), - isCurrentAccountBinding: () => true, - apiBaseUrl: 'https://square.example', - }); - - await expect(readFile(destinationPath)).resolves.toEqual(archive); - }); - - it('rejects an unsafe redirect and never forwards Bearer credentials to redirects', async () => { - const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { - status: 302, - headers: { Location: 'https://storage.example/private.zip' }, - })); - - await expect(saveLearningProjectArchive({ - project: project(), - destinationPath: join(root, 'project.zip'), - binding, - fetchImpl, - getAccessToken: vi.fn().mockResolvedValue('works-token'), - isCurrentAccountBinding: () => true, - apiBaseUrl: 'https://square.example', - })).rejects.toMatchObject({ code: 'LEARNING_DOWNLOAD_REDIRECT_INVALID' }); - expect(fetchImpl).toHaveBeenCalledTimes(1); - }); - - it('removes partial files after an integrity failure', async () => { - const fetchImpl = vi.fn().mockResolvedValue(new Response(archive, { - status: 200, - headers: { 'Content-Type': 'application/zip', 'Content-Length': String(archive.length) }, - })); - - await expect(saveLearningProjectArchive({ - project: project({ archiveSha256: 'f'.repeat(64) }), - destinationPath: join(root, 'project.zip'), - binding, - fetchImpl, - getAccessToken: vi.fn().mockResolvedValue('works-token'), - isCurrentAccountBinding: () => true, - apiBaseUrl: 'https://square.example', - })).rejects.toMatchObject({ code: 'LEARNING_ARCHIVE_HASH_MISMATCH' }); - await expect(readdir(root)).resolves.toEqual([]); - }); - - it('normalizes untrusted archive names to a ZIP file name', () => { - expect(safeLearningProjectArchiveFileName('../bad.exe', 'project:1')).toBe('Makelore-project-1.zip'); - expect(safeLearningProjectArchiveFileName('机械臂.zip', 'project-1')).toBe('机械臂.zip'); - }); -}); diff --git a/tests/unit/learning-project-image.test.tsx b/tests/unit/learning-project-image.test.tsx deleted file mode 100644 index 6a9864c..0000000 --- a/tests/unit/learning-project-image.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import { ProjectImage } from '@/pages/Learning/ProjectImage'; - -describe('Learning ProjectImage', () => { - it('renders a credential-free HTTPS image only when direct loading is allowed', () => { - const { rerender } = render( - , - ); - - expect(screen.getByAltText('项目结构图')).toHaveAttribute( - 'src', - 'https://images.example.com/diagram.svg', - ); - - rerender( - , - ); - - expect(screen.queryByAltText('项目结构图')).not.toBeInTheDocument(); - expect(screen.getByRole('img', { name: '项目结构图加载失败' })).toBeInTheDocument(); - }); -}); diff --git a/tests/unit/learning-route.test.ts b/tests/unit/learning-route.test.ts deleted file mode 100644 index 319b79c..0000000 --- a/tests/unit/learning-route.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'node:http'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createLearningRouteHandler } from '@electron/api/routes/learning'; - -vi.mock('electron', () => ({ - app: { getPath: vi.fn(() => 'C:\\Downloads') }, - dialog: { showSaveDialog: vi.fn() }, -})); - -const ARCHIVE_HASH = 'a'.repeat(64); - -function project(overrides: Record = {}) { - return { - id: 'project-1', - name: '机械臂入门', - summary: '从零搭建桌面机械臂。', - cover: { - url: '/api/learning/projects/project-1/media/cover', - alt: '机械臂封面', - width: 1600, - height: 900, - internalKey: 'private/cover.webp', - }, - tags: ['机器人', 'Python'], - version: '1.2.0', - archiveBytes: 1024, - publishedAt: '2026-08-01T00:00:00Z', - updatedAt: '2026-08-18T00:00:00Z', - readmeMarkdown: '# 机械臂入门', - archiveFileName: 'robot-arm.zip', - archiveSha256: ARCHIVE_HASH, - objectStorageKey: 'private/project.zip', - ...overrides, - }; -} - -function envelope(data: unknown): Response { - return new Response(JSON.stringify({ success: true, data }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); -} - -function createResponse() { - const chunks: string[] = []; - const res = { - statusCode: 0, - setHeader: vi.fn(), - end: vi.fn((chunk?: string) => { - if (chunk) chunks.push(chunk); - }), - } as unknown as ServerResponse; - return { - res, - get json() { - return JSON.parse(chunks.join('')) as Record; - }, - }; -} - -describe('Learning project Main route boundary', () => { - const fetchImpl = vi.fn(); - const getAccessToken = vi.fn(); - - beforeEach(() => { - fetchImpl.mockReset(); - getAccessToken.mockReset(); - }); - - it.each([ - '/api/works/projects', - '/api/works/learning/courses', - '/api/works/learning/projects/project-1/unknown', - ])('does not claim old or unrelated route %s', async (pathname) => { - const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); - const response = createResponse(); - - await expect(handler( - { method: 'GET' } as IncomingMessage, - response.res, - new URL(`http://127.0.0.1${pathname}`), - {} as never, - )).resolves.toBe(false); - expect(fetchImpl).not.toHaveBeenCalled(); - }); - - it('forwards bounded pagination and strictly projects project cards', async () => { - getAccessToken.mockResolvedValue('works-token'); - fetchImpl.mockResolvedValue(envelope({ - items: [project()], - nextCursor: 'next-page', - total: 1, - internalFacet: 'drop-me', - })); - const handler = createLearningRouteHandler({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example/' }); - const response = createResponse(); - - await handler( - { method: 'GET' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects?cursor=opaque&limit=24&unknown=no'), - {} as never, - ); - - expect(fetchImpl).toHaveBeenCalledWith( - 'https://square.example/api/learning/projects?cursor=opaque&limit=24', - expect.objectContaining({ - method: 'GET', - headers: { Accept: 'application/json', Authorization: 'Bearer works-token' }, - redirect: 'manual', - }), - ); - expect(response.json).toEqual({ - success: true, - data: { - items: [{ - id: 'project-1', - name: '机械臂入门', - summary: '从零搭建桌面机械臂。', - cover: { - url: '/api/learning/projects/project-1/media/cover', - alt: '机械臂封面', - width: 1600, - height: 900, - }, - tags: ['机器人', 'Python'], - version: '1.2.0', - archiveBytes: 1024, - publishedAt: '2026-08-01T00:00:00Z', - updatedAt: '2026-08-18T00:00:00Z', - }], - nextCursor: 'next-page', - total: 1, - }, - }); - expect(JSON.stringify(response.json)).not.toContain('objectStorageKey'); - }); - - it('projects detail Markdown and archive integrity metadata without storage keys', async () => { - getAccessToken.mockResolvedValue('works-token'); - fetchImpl.mockResolvedValue(envelope(project())); - const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); - const response = createResponse(); - - await handler( - { method: 'GET' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects/project-1'), - {} as never, - ); - - expect(response.json).toMatchObject({ - success: true, - data: { - id: 'project-1', - readmeMarkdown: '# 机械臂入门', - archiveFileName: 'robot-arm.zip', - archiveSha256: ARCHIVE_HASH, - }, - }); - expect(JSON.stringify(response.json)).not.toContain('private/project.zip'); - }); - - it('accepts project metadata above the former client archive-size limit', async () => { - getAccessToken.mockResolvedValue('works-token'); - const archiveBytes = 512 * 1024 * 1024 + 1; - fetchImpl.mockResolvedValue(envelope(project({ archiveBytes }))); - const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); - const response = createResponse(); - - await handler( - { method: 'GET' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects/project-1'), - {} as never, - ); - - expect(response.json).toMatchObject({ - success: true, - data: { id: 'project-1', archiveBytes }, - }); - }); - - it('rejects malformed identities and unsafe media URLs in successful DTOs', async () => { - getAccessToken.mockResolvedValue('works-token'); - fetchImpl.mockResolvedValue(envelope({ - items: [project({ cover: { url: 'http://internal.example/cover.png', alt: '不安全' } })], - nextCursor: null, - })); - const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); - const response = createResponse(); - - await handler( - { method: 'GET' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects'), - {} as never, - ); - - expect(response.res.statusCode).toBe(502); - expect(response.json).toMatchObject({ code: 'LEARNING_INVALID_RESPONSE' }); - }); - - it('proxies only fixed raster media paths with Works authentication', async () => { - getAccessToken.mockResolvedValue('works-token'); - fetchImpl.mockResolvedValue(new Response(Uint8Array.from([1, 2, 3]), { - status: 200, - headers: { 'Content-Type': 'image/webp', 'Content-Length': '3' }, - })); - const handler = createLearningRouteHandler({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' }); - const response = createResponse(); - - await handler( - { method: 'GET' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects/project-1/media/readme-1'), - {} as never, - ); - - expect(fetchImpl).toHaveBeenCalledWith( - 'https://square.example/api/learning/projects/project-1/media/readme-1', - expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer works-token' }) }), - ); - expect(response.json).toEqual({ dataBase64: 'AQID', mimeType: 'image/webp' }); - }); - - it('uses project metadata and a native destination before delegating verified download', async () => { - getAccessToken.mockResolvedValue('works-token'); - fetchImpl.mockResolvedValue(envelope(project())); - const chooseDestination = vi.fn().mockResolvedValue('D:\\Downloads\\robot-arm.zip'); - const saveArchive = vi.fn().mockResolvedValue(undefined); - const binding = { accountKey: 'b'.repeat(64), epoch: 1 }; - const handler = createLearningRouteHandler({ - fetchImpl, - getAccessToken, - getAccountBinding: () => binding, - isCurrentAccountBinding: () => true, - chooseDestination, - saveArchive, - apiBaseUrl: 'https://square.example', - }); - const response = createResponse(); - - await handler( - { method: 'POST' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects/project-1/download'), - { mainWindow: null } as never, - ); - - expect(chooseDestination).toHaveBeenCalledWith(expect.anything(), 'robot-arm.zip'); - expect(saveArchive).toHaveBeenCalledWith(expect.objectContaining({ - destinationPath: 'D:\\Downloads\\robot-arm.zip', - binding, - project: expect.objectContaining({ id: 'project-1', archiveSha256: ARCHIVE_HASH }), - })); - expect(response.json).toEqual({ success: true, data: { status: 'saved' } }); - }); - - it('does not download when the user cancels the native save dialog', async () => { - getAccessToken.mockResolvedValue('works-token'); - fetchImpl.mockResolvedValue(envelope(project())); - const saveArchive = vi.fn(); - const handler = createLearningRouteHandler({ - fetchImpl, - getAccessToken, - getAccountBinding: () => ({ accountKey: 'b'.repeat(64), epoch: 1 }), - isCurrentAccountBinding: () => true, - chooseDestination: vi.fn().mockResolvedValue(null), - saveArchive, - }); - const response = createResponse(); - - await handler( - { method: 'POST' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects/project-1/download'), - {} as never, - ); - - expect(saveArchive).not.toHaveBeenCalled(); - expect(response.json).toEqual({ success: true, data: { status: 'cancelled' } }); - }); - - it('refreshes one upstream 401 and redacts unavailable-service details', async () => { - getAccessToken.mockResolvedValueOnce('expired').mockResolvedValueOnce('fresh'); - fetchImpl - .mockResolvedValueOnce(new Response('expired secret', { status: 401 })) - .mockResolvedValueOnce(envelope({ items: [], nextCursor: null })); - const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); - const response = createResponse(); - - await handler( - { method: 'GET' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects'), - {} as never, - ); - - expect(getAccessToken).toHaveBeenNthCalledWith(2, { fetchImpl, forceRefresh: true }); - expect(response.json).toEqual({ success: true, data: { items: [], nextCursor: null } }); - }); -}); diff --git a/tests/unit/learning-sidebar.test.tsx b/tests/unit/learning-sidebar.test.tsx deleted file mode 100644 index e28074b..0000000 --- a/tests/unit/learning-sidebar.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; -import { LearningSidebar } from '@/components/layout/LearningSidebar'; - -function LocationProbe() { - const location = useLocation(); - return {location.pathname}; -} - -describe('LearningSidebar', () => { - it('shows only the new project catalog navigation and returns detail pages to the list', () => { - render( - - - } /> - - , - ); - - expect(screen.getByRole('navigation', { name: 'AI 学习导航' })).toHaveTextContent('学习项目'); - expect(screen.queryByText('生成课程')).not.toBeInTheDocument(); - expect(screen.queryByText('我的课程')).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '学习项目' })); - expect(screen.getByTestId('location-path')).toHaveTextContent('/learning'); - }); - - it('keeps the collapsed navigation compact', () => { - render(); - expect(screen.getByRole('button', { name: '学习项目' })).toBeInTheDocument(); - expect(screen.queryByText('阅读项目说明并下载到电脑实践。')).not.toBeInTheDocument(); - }); -}); diff --git a/tests/unit/main-layout-module-gate.test.tsx b/tests/unit/main-layout-module-gate.test.tsx index 827d9c7..37bb229 100644 --- a/tests/unit/main-layout-module-gate.test.tsx +++ b/tests/unit/main-layout-module-gate.test.tsx @@ -7,7 +7,7 @@ import { useProjectConfigStore } from '@/stores/project-config'; import { useSettingsStore } from '@/stores/settings'; import { createCodingProjectConfigV2 } from '@electron/coding-projects/project-config'; -const learningIpcMock = vi.hoisted(() => vi.fn()); +const routeRenderMock = vi.hoisted(() => vi.fn()); vi.mock('@/components/layout/Sidebar', () => ({ Sidebar: ({ sidebarCollapsedOverride }: { sidebarCollapsedOverride?: boolean }) => ( @@ -26,8 +26,8 @@ vi.mock('@/components/layout/TitleBar', () => ({ ), })); -function LearningRouteContent() { - learningIpcMock(); +function RouteContent() { + routeRenderMock(); return
; } @@ -36,7 +36,7 @@ function renderLayout(path: string) { }> - } /> + } /> , @@ -45,7 +45,7 @@ function renderLayout(path: string) { describe('MainLayout module isolation', () => { beforeEach(() => { - learningIpcMock.mockReset(); + routeRenderMock.mockReset(); const project = { id: 'local-project', path: '/tmp/local-project', @@ -118,16 +118,4 @@ describe('MainLayout module isolation', () => { expect(screen.getByTestId('main-content')).toHaveClass('basis-0', 'overflow-hidden', 'p-0', 'sm:p-6'); }); - it.each(['/learning', '/learning/project/project-1'])( - 'keeps the project catalog route %s in the standard integrated shell', - (path) => { - renderLayout(path); - - expect(screen.getByTestId('sidebar-stub')).toBeVisible(); - expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-integrated', 'true'); - expect(screen.getByTestId('main-content')).toHaveClass('p-5', 'sm:p-6'); - expect(screen.getByTestId('route-content')).toBeVisible(); - expect(screen.queryByTestId('learning-profile-sync-error-gate')).not.toBeInTheDocument(); - }, - ); }); diff --git a/tests/unit/module-access.test.ts b/tests/unit/module-access.test.ts new file mode 100644 index 0000000..d4bed59 --- /dev/null +++ b/tests/unit/module-access.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_MODULE_ACCESS, + MODULE_ACCESS_KEYS, + normalizeModuleAccess, +} from '../../shared/module-access'; + +describe('module access projection', () => { + it('contains only the three supported product modules', () => { + expect(MODULE_ACCESS_KEYS).toEqual(['programming', 'design', 'robot']); + expect(DEFAULT_MODULE_ACCESS).toEqual({ + programming: true, + design: true, + robot: true, + }); + }); + + it('drops the retired Learning key from legacy or upstream policy data', () => { + const access = normalizeModuleAccess({ + programming: false, + design: true, + learning: false, + robot: false, + }); + + expect(access).toEqual({ + programming: false, + design: true, + robot: false, + }); + expect(access).not.toHaveProperty('learning'); + }); +}); diff --git a/tests/unit/module-navigation.test.tsx b/tests/unit/module-navigation.test.tsx index f2d4e44..6db5c21 100644 --- a/tests/unit/module-navigation.test.tsx +++ b/tests/unit/module-navigation.test.tsx @@ -52,7 +52,7 @@ describe('AI module navigation', () => { }); }); - it('shows the four module choices and routes Canvas from the chooser', async () => { + it('shows only the three supported module choices and routes Canvas from the chooser', async () => { render( @@ -68,9 +68,11 @@ describe('AI module navigation', () => { expect(screen.getByTestId('ai-module-options')).toHaveClass('module-selection-options'); expect(await screen.findByText('小明,欢迎回来!')).toBeInTheDocument(); expect(screen.getByTestId('ai-module-option-programming')).toHaveClass('module-option-card', 'module-option-card-horizontal'); - for (const moduleId of ['programming', 'painting', 'learning', 'robot'] as const) { + for (const moduleId of ['programming', 'painting', 'robot'] as const) { expect(screen.getByTestId(`ai-module-option-${moduleId}`).querySelector('.module-option-artwork')).toBeNull(); } + expect(screen.getAllByTestId(/^ai-module-option-/u)).toHaveLength(3); + expect(screen.queryByTestId('ai-module-option-learning')).not.toBeInTheDocument(); expect(screen.queryByText('先选一个入口。进入后,你还可以从左下角随时切换到其他模块。')).not.toBeInTheDocument(); expect(screen.queryByText('选择创作空间。你可以随时从侧边栏切换。')).not.toBeInTheDocument(); expect(screen.getByAltText('Makelore')).toHaveClass('h-8', 'w-40', 'object-cover'); @@ -82,12 +84,6 @@ describe('AI module navigation', () => { expect(screen.getByText('Canvas 设计')).toBeInTheDocument(); expect(screen.getByAltText('数位板设计创作')).toBeInTheDocument(); expect(screen.getByTestId('ai-module-option-painting')).toHaveTextContent('用灵感描绘色彩缤纷的世界'); - const learningOption = screen.getByTestId('ai-module-option-learning'); - expect(learningOption).toBeEnabled(); - expect(learningOption).not.toHaveClass('module-option-card-disabled'); - expect(learningOption).toHaveTextContent('Learning 学习'); - expect(learningOption).toHaveTextContent('用顶尖的方法解锁万物规律'); - expect(screen.getByAltText('数学科技宇宙')).toBeInTheDocument(); const robotOption = screen.getByTestId('ai-module-option-robot'); expect(robotOption).toBeEnabled(); expect(robotOption).not.toHaveClass('module-option-card-disabled'); @@ -95,8 +91,8 @@ describe('AI module navigation', () => { expect(robotOption).toHaveTextContent('制作你的第一个机器人小伙伴'); expect(screen.getByAltText('青少年管理机器人硬件与设备绑定')).toBeInTheDocument(); - fireEvent.click(learningOption); - expect(await screen.findByTestId('location-path')).toHaveTextContent('/learning'); + fireEvent.click(screen.getByTestId('ai-module-option-painting')); + expect(await screen.findByTestId('location-path')).toHaveTextContent('/image-canvas'); }); it('routes Robot from the chooser to the AI hardware workspace', async () => { @@ -117,8 +113,7 @@ describe('AI module navigation', () => { useAuthStore.setState({ moduleAccess: { programming: true, - design: true, - learning: false, + design: false, robot: true, }, user: { @@ -139,12 +134,12 @@ describe('AI module navigation', () => { , ); - const learningOption = screen.getByTestId('ai-module-option-learning'); - expect(learningOption).toBeDisabled(); - expect(learningOption).toHaveClass('module-option-card-disabled'); - expect(learningOption).toHaveAttribute('aria-disabled', 'true'); + const canvasOption = screen.getByTestId('ai-module-option-painting'); + expect(canvasOption).toBeDisabled(); + expect(canvasOption).toHaveClass('module-option-card-disabled'); + expect(canvasOption).toHaveAttribute('aria-disabled', 'true'); - fireEvent.click(learningOption); + fireEvent.click(canvasOption); expect(screen.getByTestId('ai-module-selection-page')).toBeInTheDocument(); expect(screen.queryByTestId('location-path')).not.toBeInTheDocument(); }); @@ -274,20 +269,6 @@ describe('AI module navigation', () => { expect(await screen.findByTestId('location-path')).toHaveTextContent('/module-select'); }); - it('shows Learning as the active module and returns to the chooser from the learning route', async () => { - render( - - - } /> - - , - ); - - expect(screen.getByTestId('sidebar-module-switcher-trigger')).toHaveTextContent('学习 Learning'); - fireEvent.click(screen.getByTestId('sidebar-module-switcher-trigger')); - expect(await screen.findByTestId('location-path')).toHaveTextContent('/module-select'); - }); - it('keeps breathing room around the compact module switcher trigger', () => { render( From 5f87a42d598914cf644d8c388ec39da238e9c8a2 Mon Sep 17 00:00:00 2001 From: inman Date: Fri, 4 Sep 2026 12:15:32 +0800 Subject: [PATCH 03/22] docs: integrate Learning removal --- .project-docs/00-brief/success-criteria.md | 9 +- .../adr-005-learning-project-catalog.md | 18 +++- .project-docs/10-decisions/decision-index.md | 2 +- .project-docs/20-architecture/data-flow.md | 12 +-- .project-docs/20-architecture/module-map.md | 5 - .../20-architecture/system-overview.md | 15 ++- .project-docs/30-worklog/current-state.md | 53 +++++++--- ...0904-integrate-remove-learning-6e4a9c21.md | 99 +++++++++++++++++++ .project-docs/40-domain/business-rules.md | 11 +-- .project-docs/40-domain/glossary.md | 4 - .project-docs/80-commitments/commitments.md | 4 +- 11 files changed, 174 insertions(+), 58 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260904-integrate-remove-learning-6e4a9c21.md diff --git a/.project-docs/00-brief/success-criteria.md b/.project-docs/00-brief/success-criteria.md index 711821a..c102db2 100644 --- a/.project-docs/00-brief/success-criteria.md +++ b/.project-docs/00-brief/success-criteria.md @@ -11,12 +11,10 @@ - 上传协议必须同时携带源码归档、构建归档和严格版本化 artifact contract;服务端独立重算摘要、校验合同并固化不可变 Release。 - Renderer 不得获得发布凭据、归档、临时目录、构建 origin 或任意本地路径;旧客户端和旧 sandbox/browser 任务必须提示升级后重新构建提交。 - Works Project 首次 create 必须用单一 multipart 合同原子提交文字资料与必选的 PNG/JPEG/WebP 封面;创建失败或冲突不得继续上传版本。已有项目保持 version-only;在没有 metadata revision/条件写前,客户端不得通过无条件 PATCH 修改已有资料。 -- Learning 必须保持登录和 `module_access.learning` 门禁,并只呈现服务端已发布的分页项目列表、README 详情和下载动作;旧课程生成、进度、本地库、播放器、Agent/ASR/runtime 代码和打包依赖不得继续存在。 -- Learning README 必须禁用原始 HTML;远程 HTTPS raster 图片由 Works Square 在发布时校验、镜像并改写为固定媒体路径,Main 以 Bearer 代理并限制可信 MIME 和 10 MiB,Renderer 不获得任意网络代理能力。 -- Learning 项目下载必须由 Main 打开原生保存窗口并写入临时文件,不按 `Content-Length`、`archiveBytes`、实际流字节数或客户端上限阻断;最多五跳同源重定向,校验 SHA-256 和 ZIP 签名后再原子落盘。Renderer 只能获得 `saved` 或 `cancelled`,不得获得 Token、上游 URL 或本地路径。 +- Learning 入口、路由、Renderer、Main Host API、共享 DTO、素材、打包依赖和兼容 fallback 必须保持移除;旧 Learning URL 回到模块选择页,旧 Host API 路径返回标准 not-found。 - 历史课程数据不得被新逻辑读取或迁移,也不得在没有独立用户确认与恢复策略时自动删除。 - Robot Guided Hotspot Binding 必须保持固件零改动、Main-owned default-on capability、精确环境值 `0` 回滚、固定系统浏览器 Portal 和现有六位 Binding facade。Windows/macOS 页面内只能扫描开放 `Xiaozhi-*` 短效候选并连接用户明确选择的项;Makelore 不得接触家庭 Wi-Fi 凭据,也不得把热点发现或 Binding 成功等同于可信身份/在线。 -- 用户模块入口策略必须由 Electron Main 将 Works `/api/auth/me` 安全投影为四个布尔值;缺失对象/字段默认开启,`design` 映射 `painting`,Renderer 不获得 Token 或原始 profile。 +- 用户模块入口策略必须由 Electron Main 将 Works `/api/auth/me` 安全投影为 Code、Canvas、Robot 三个布尔值;缺失对象/字段默认开启,`design` 映射 `painting`,额外旧字段被忽略,Renderer 不获得 Token 或原始 profile。 - 被关闭模块必须在卡片和根/深层/别名路由两层均无法进入,且在 `MainLayout`/模块初始化前阻断;Code provider 等待 policy hydration,全局 `/settings` 仍可访问,终止性 `401` 清理 Main/Renderer 会话。该客户端入口策略不能代替 API 授权。 - Makelore Code 必须只使用精确 pin 的 Pi `0.84.2` production runtime;产品源码、安装包和资源根不得保留 OpenCode runtime/package/plugin/fallback/双路径。上游 Pi Provider adapter 的精确静态文件例外必须单列,不能误报为产品旧 runtime。 - 新 project/Agent/Conversation 必须在 `.makelore` 中本地原子创建,不读取或迁移 `.niancode` / `.opencode` 项目元数据;Composer 在逻辑线程准备期间仍可编辑。每条 active/warm Conversation 在单一父 Agent Server 内独立绑定 Runtime/Session/channel/credential/extension context;Snapshot/Patch、model/thinking、queue、interaction、subagent 和 recovery 不得跨 Conversation 泄漏。 @@ -29,9 +27,8 @@ - 发布安装包前运行 `pnpm verify:publish-runtime`,并对最终 Windows 产物运行 `pnpm verify:artifact:win`;固定 npm 闭包缺失或版本不符时 fail closed。 - 至少覆盖 release builder/静态产物服务/发布路由/Renderer 状态的聚焦测试、typecheck、scoped lint、Vite 构建和真实 Electron 双视口 production-seam E2E。 - 真实生产发布仍需成组验证服务端合同、不可变 Release、运营审核、CDN/App 播放;客户端本地验证不能替代该整链验收。 -- Learning 上线前必须部署 Works Square 项目管理、对象存储、README HTTPS URL 校验和 list/detail/media/archive API,并使用真实账号和正式安装包验证发布/下架、分页、README 图片、下载取消、缺失或错误 `Content-Length`、损坏/摘要不匹配 ZIP,以及 Windows 与签名 macOS 保存行为。 - Robot 默认引导路径必须通过聚焦 Renderer/Main/native 测试并保留 `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` 回滚。真实 Host API/native seam Electron E2E、Windows Robot 真机、签名 macOS x64/arm64、指定固件镜像与六位码发行契约未完成前,不得宣称完整双平台硬件兼容或端到端配网已经验收。 -- 四模块入口策略发布前必须部署 Works migration/API、重新打包客户端,并以真实账号对 Code、Canvas、Learning、Robot 分别执行关闭 smoke;客户端自动化不得代替服务端 API 授权验收。 +- 三模块入口策略发布前必须部署 Works migration/API、重新打包客户端,并以真实账号对 Code、Canvas、Robot 分别执行关闭 smoke;客户端自动化不得代替服务端 API 授权验收。 - Windows 最终安装包必须通过 Pi production closure、最终 `app.asar` Main、真实 extension/ephemeral child、单一父 Agent Server 内 4 条重叠 Conversation 逻辑线程 + 4 个 child live-PID、单线程关闭、Server 崩溃重启、prompt/compact uncertainty、late-settle 和零残留进程验证。loopback/provider-shaped smoke 只能证明协议序列化与本地隔离,`realTurnVerified=false` 时不得宣称真实 Provider Pass。 - macOS x64/arm64 与 native non-WSL Linux desktop/compositor 的最终安装产物仍是 cross-platform release gate;Windows 或 WSL2/WSLg 通过不能替代这些平台证据。 diff --git a/.project-docs/10-decisions/adr-005-learning-project-catalog.md b/.project-docs/10-decisions/adr-005-learning-project-catalog.md index 6548302..be5ebd6 100644 --- a/.project-docs/10-decisions/adr-005-learning-project-catalog.md +++ b/.project-docs/10-decisions/adr-005-learning-project-catalog.md @@ -2,7 +2,12 @@ ## Status -Accepted and implemented on 2026-08-20. +Superseded on 2026-09-04 by the explicit product decision to remove Learning +from Makelore. Source `5a7cb9b` was integrated as `bd0873f` by task +`20260904-integrate-remove-learning-6e4a9c21`. + +This ADR remains only as historical context. Its catalog, route, API, download, +packaging, and release obligations are no longer active. ## Context @@ -26,6 +31,16 @@ The integrated Learning module previously implemented course generation, an acco - Removing client download-size checks avoids rejecting otherwise valid streams with missing or inaccurate size metadata, but also removes client-side protection against an archive consuming excessive disk space. Works Square may retain an independent operations upload/storage limit. - A future historical-data cleanup must be an explicit, user-controlled maintenance feature. +## Superseded Consequences + +- Makelore now exposes only Code, Canvas, and Robot as top-level modules. +- Learning Renderer, Main, shared DTO, asset, test, and server-contract sources are removed. +- Old Learning routes fall through to the module chooser and old Learning Host API paths + return the normal not-found response; no compatibility implementation is retained. +- Historical downloaded course data remains untouched and is not read or migrated. +- The literal `makelore-learning:v1` remains frozen only as a cross-module persisted + account-partition compatibility salt; its name does not represent an active module. + ## Supersedes - The Learning course-generation, local-course, OpenMAIC playback, and classroom-runtime behavior introduced by integrated source `01bee31`. @@ -43,7 +58,6 @@ The integrated Learning module previously implemented course generation, an acco ## Related -- `docs/learning-project-catalog-server-contract.md` - `.project-docs/20-architecture/system-overview.md` - `.project-docs/20-architecture/data-flow.md` - `.project-docs/40-domain/business-rules.md` diff --git a/.project-docs/10-decisions/decision-index.md b/.project-docs/10-decisions/decision-index.md index f98edc2..01ccc25 100644 --- a/.project-docs/10-decisions/decision-index.md +++ b/.project-docs/10-decisions/decision-index.md @@ -8,7 +8,6 @@ | ADR-002 | Robot V1 采用 Main 门控的引导式热点配网并衔接现有六位 Binding | Accepted / implemented, default on | 2026-08-16 | Robot Renderer、Host API、Electron Main、现有固件热点入口 | `adr-002-robot-guided-hotspot-binding-v1.md` | | ADR-003 | Robot 配网页内扫描并连接 Windows/macOS 热点 | Accepted / implemented with physical release gates pending | 2026-08-16 | Robot Renderer、Host API、Electron Main、Windows WLAN、macOS CoreWLAN/CoreLocation | `adr-003-robot-in-app-hotspot-connection.md` | | ADR-004 | Works Square 统一拥有桌面认证生命周期边界 | Accepted / implemented | 2026-08-19 | Renderer、Host API、Electron Main、Works Square auth facade | `adr-004-square-auth-lifecycle-boundary.md` | -| ADR-005 | Learning 采用运营精选项目目录、直接渲染经校验的 HTTPS README 图片、下载不做大小校验并退役课程运行时 | Accepted / implemented, amended 2026-08-20 | 2026-08-20 | Learning Renderer、Host API、Electron Main、Works Square operations/API | `adr-005-learning-project-catalog.md` | | ADR-006 | Makelore Code 以 Pi `0.84.2` 为唯一 runtime,父 Conversation 复用一个 Main-owned Agent Server 并隔离逻辑 Runtime/Session/provider/lease,产品只暴露 Snapshot/Patch 合同 | Accepted / implemented, amended 2026-08-31 | 2026-08-26 | Code Renderer、Host API、Electron Main、Pi runtime、Provider/resource、packaging | `adr-006-pi-runtime-hard-cutover.md` | ## Superseded Decisions @@ -16,6 +15,7 @@ | ID | Decision | Superseded By | Date | |---|---|---|---| | ADR-001 | AI 绘画 Workspace / Conversation 状态归属 | ADR-007 | 2026-08-30 | +| ADR-005 | Learning 运营精选项目目录与下载边界 | 2026-09-04 用户产品决定:从 Makelore 移除 Learning;源 `5a7cb9b`,集成 `bd0873f` | 2026-09-04 | ## Decision Criteria diff --git a/.project-docs/20-architecture/data-flow.md b/.project-docs/20-architecture/data-flow.md index cd6a2d4..4e76e1d 100644 --- a/.project-docs/20-architecture/data-flow.md +++ b/.project-docs/20-architecture/data-flow.md @@ -12,7 +12,7 @@ | Effective Plugin worker snapshot | Installed trusted package or acquired official bundled definition + project selection + Agent assignments + current server policy | effective resolver → Registry/resource loader/Extension Host/tool catalog → parent Pi worker | One frozen snapshot supplies Skills, tools, package roots, and runtime authorization. Disable, account/project switch, logout, Renderer crash, Main shutdown, or worker generation change invalidates future actions without mutating persisted unknown assignments; child workers receive no Plugin projection. | | Hosted Game Resource operation | Eligible parent `makelore.game-resource` tool call plus explicit confirmation | frozen Plugin adapter → capability Registry → Main `GameResourceClient` → fixed Works Square game-resource route → provider-neutral receipt/result | Server policy owns pricing, payer, Admission and receipt state. Stable logical operation identity survives response loss/Main restart; `submission_unknown` is not replayed as a fresh request. Result saving uses a bounded project-relative path and the existing project write lease. | | 桌面认证生命周期 | Renderer 登录、刷新与注销请求 | Host API → Main Works Session → Works Square `/api/auth/{login,mobile-login,refresh,logout}` → one-feel auth | Main 加密持有并先持久化轮换 token;客户端不携带 OAuth client secret;连续 7 天未使用才清除会话,终止性 `400`/`401` fail closed | -| 用户模块入口策略 | 会话恢复 / 登录 / 刷新 | Electron Main → Works `/api/auth/me` → 四布尔安全投影 → Renderer auth store → 卡片/路由/provider gate | 缺失对象或字段默认 `true`;`design` 映射 `painting`;终止性 `401` 清理 Main/Renderer 会话;全局 `/settings` 不受 Code gate | +| 用户模块入口策略 | 会话恢复 / 登录 / 刷新 | Electron Main → Works `/api/auth/me` → 三布尔安全投影 → Renderer auth store → 卡片/路由/provider gate | 缺失对象或字段默认 `true`;`design` 映射 `painting`;额外旧字段被忽略;终止性 `401` 清理 Main/Renderer 会话;全局 `/settings` 不受 Code gate | | 项目创建 | 新建项目对话框 | Host API → Main 项目初始化 | 创建时固定 `ProjectType`;小游戏/小程序原子生成受控模板,自定义只生成项目空间 | | 一键提交 | `ProjectPublishAction` | Renderer capability → Host API → Main 本地 npm/Vite build → built snapshot preflight → source+built+contract 上传 | 只对小游戏/小程序开放;首次 create 通过单一 multipart 合同原子提交文字资料与必选 PNG/JPEG/WebP 封面,创建失败或冲突不上传版本;已有 draft/published 只提交版本并沿用云端资料/封面,不做无条件 metadata PATCH | | 构建产物预检 | Main-owned built snapshot | 一次性 loopback origin → fresh Electron WebContents/CDP(桌面、移动) | 检查错误、白屏和外域;不调用 Playwright,检查与上传归档相同字节,但不产生可信 receipt | @@ -35,8 +35,6 @@ | Design Task and Asset sync | Direction event stream / Workspace resource events / explicit refresh | Renderer Task/Asset projections | Task/Asset events独立于 Living Form;transport order 和 progress 不改写 Specification,也不授权新生成操作 | | 设计项目删除 | Canvas 侧栏精确项目名确认 | Renderer → Main Host API → Works Square Workspace DELETE | 删除成功后清理当前 Workspace/Direction/resources 并选择最近更新的剩余项目;结算与软删除语义由服务端负责 | | Prompt Museum 浏览与使用 | Canvas 侧栏“获取灵感” | Renderer → Main Host API → Works Square list/detail;选中 Prompt → 进程内 pending state → 当前 Canvas 输入框 | 只发送白名单筛选/游标;Works Token 留在 Main,Prompt 不自动发送,Museum 不包含客户端静态数据集 | -| Learning 项目浏览 | Learning 项目列表 / README 详情 | Renderer → typed Host API → Main fixed list/detail routes → Works Square published projects;README 图片节点 → 远程 HTTPS origin | 保留登录与 `module_access.learning`;Main 严格投影分页项目 DTO,README 禁用原始 HTML,仅图片节点可直连无凭据 HTTPS URL;封面和历史媒体仍使用受控媒体读取 | -| Learning 项目下载 | README 详情页“下载项目” | Renderer → Host API → Main 原生保存对话框 → Works archive stream → 临时文件 → 原子重命名 | 不校验 `Content-Length`、`archiveBytes`、实际流字节数或客户端大小上限;最多五跳同 Works origin 重定向且不向重定向请求携带 Bearer;SHA-256 与 ZIP 签名匹配后才保存,Renderer 只得到 `saved` 或 `cancelled` | | Robot 引导式热点配网 V1(已实现、默认开启) | Robot Binding 页面 | 用户选择引导配网 → 进入固件配网模式 → Renderer 经 Host API 请求 Main 扫描 → 用户选择短效候选 → Windows/macOS Adapter 连接并核验当前 SSID → Main 打开固定 Portal → 用户在 Portal 配置 Wi-Fi → 电脑恢复互联网 → 现有六位 Binding | 精确环境值 `0` 或 capability 读取失败回退直接六位码;系统 Wi-Fi 保留兜底,Makelore 不收集 Wi-Fi 密码、不修改固件,热点发现/`bound` 都不等于可信身份或 online/ready | ## State Ownership @@ -44,13 +42,14 @@ - Main 持有刷新凭据、发布 Token、固定 npm runtime、源码/构建归档、临时目录、幂等键和 submission binding v2;Renderer 不持有归档路径、构建 origin 或自动部署状态。 - 项目内 `.makelore/project.json` 是 `ProjectType`、Agent 与 Coding 项目配置的唯一权威;Main 在配置写入和目录复用时保持 `ProjectType` 不可变,并在打包时重新读取校验。 - Renderer 仅持有短效公开会话状态和提交展示状态。 -- Renderer 可持久化当前账号的四布尔模块入口策略,但不持有原始 Works profile 或 Token。新账号不继承上一账号缓存;网络/暂时上游失败可保留同会话已知策略,终止性 `401` 不得回退到默认开启。 +- Renderer 可持久化当前账号的三布尔模块入口策略,但不持有原始 Works profile 或 Token。新账号不继承上一账号缓存;网络/暂时上游失败可保留同会话已知策略,终止性 `401` 不得回退到默认开启。 - 本地构建临时目录、HTTP origin 和预检 WebContents/partition 只属于一次调用;预检读取与 `built_archive` 相同的内存字节,但结果不写为可信上传 receipt,也不覆盖生产 opaque-origin。 - 旧 schema v1 `submitted` 记录迁移并保留;旧 `armed`、`waiting_for_package`、`waiting_for_login`、`uploading`、`failed` 归一为 `legacy_retired`,不再启动 watcher 或上传任务。 - AI Design Current Specification 与 current Direction 是语义权威;conversation timeline 只保存交互历史,immutable Quote 绑定精确 Specification revision,Task/Asset 属于 Workspace resource projection。 - AI 绘画 Main 持有 Workspace 请求 deadline、底层 transport fallback 和共享 Works token refresh flight。共同等待者必须在同一有限期限内 settle,refresh flight 结束后必须释放,后续刷新可重新发起;底层 mutation transport failure 不得自动产生第二次上游请求。 - Prompt Museum pending Prompt 是 Renderer 进程内一次性导航状态;Canvas 消费后立即清除,不进入 Workspace/Specification,直到用户主动提交 Design input。 -- Learning 不再维护客户端课程库、生成任务、player registration、进度或 runtime 状态。Main 在一次下载调用内持有当前账号快照、Works 凭据、保存路径和临时文件;账号变化或校验失败会中止并清理临时文件。历史课程数据不会被新逻辑读取,也不会自动删除。 +- Retired Learning data is outside the active client state model. Historical downloaded + course data is not read, migrated, or automatically deleted. - 图生图参考图与视频首帧都先归一为当前 Workspace Asset,再通过 typed binding 写入 Specification;本地路径不会进入 canonical contract。 - Development 与 packaged Canvas 都使用 Main-owned Works Square V2 adapter。V1 DTO/local semantic adapter 与 local/cloud mode switches 已删除,不存在失败回退。 - 注销和退出关闭本地 event stream、清除 drafts/pending state 和凭据;服务端 current Direction Session 与 semantic history 保持持久化。 @@ -68,14 +67,13 @@ ## External Interfaces - Works Square 项目创建、版本上传、构建状态与 Release 状态 API。 -- Works Square `/api/auth/me` 模块权限 API;Electron Main 持有 Bearer 并只向 Renderer 投影 `programming`/`design`/`learning`/`robot` 对应的四个布尔值。 +- Works Square `/api/auth/me` 模块权限 API;Electron Main 持有 Bearer 并只向 Renderer 投影 `programming`/`design`/`robot` 对应的三个布尔值,额外旧字段被忽略。 - 本机 Host API 的发布路由;发布路由要求 Renderer capability。 - 本机 `/api/coding/*` project/Agent/Conversation、Snapshot/patch-batch SSE、prompt/compact/fork/recover、attachment/file/interaction/diagnostics 路由,以及 Main-owned AI 模型代理。 - Main-owned electron-updater IPC 与 Works Square 平台/架构稳定 feed;正式安装产物发布不由 Renderer 控制。 - 服务端安全投影后的公共 `play_url`;只接受同源 HTTPS、精确 App 路径和可信版本状态。 - Works Square Design V2 Workspace/Direction/Living Form API、持久 Agent Gateway Session、Direction event stream,以及 `design.input.apply`、`design.quote.request`、`design.generation.confirm` stable-operation contract。 - Works Square Prompt Museum list/detail API;Main 添加当前账号 Bearer Token,Renderer 只使用 Host API 投影。 -- Works Square Learning project list/detail/media/archive API;Main 添加当前账号 Bearer Token,并限制固定路径、DTO、封面/历史媒体 MIME/大小、归档重定向、摘要和 ZIP 签名;归档大小字段只作展示,不参与下载门禁。README 中经服务端校验的无凭据 HTTPS 图片由 Renderer 图片节点直接请求。 - 已实现的本机 Robot provisioning capability、固定 portal-open 与 hotspot scan/connect Host API。它们是本地 Main 操作,不读取 Works access token、不调用上游,也不接受任意 URL/SSID/BSSID/interface/profile。 ## Last Updated diff --git a/.project-docs/20-architecture/module-map.md b/.project-docs/20-architecture/module-map.md index 5b9fc8c..4251c83 100644 --- a/.project-docs/20-architecture/module-map.md +++ b/.project-docs/20-architecture/module-map.md @@ -42,9 +42,6 @@ | `shared/image-prompt-museum.ts` | Prompt Museum 列表、分类、详情、署名与分页共享 DTO | 客户端不包含内容数据集,只定义服务端字段契约 | | `electron/api/routes/image-prompt-museum.ts` | Main-owned Museum 列表/详情代理与 Works 登录态 | 仅 GET 固定路径和白名单查询;Renderer 不获得 Bearer Token | | `src/pages/ImagePromptMuseum/index.tsx` / `src/lib/image-prompt-museum.ts` / `src/stores/image-prompt-museum.ts` | Museum 搜索/筛选/详情与一次性 Prompt 回填 | 原 Prompt 只带回 Canvas 输入框,不自动发送;页面不接受投稿或互动 | -| `shared/learning.ts` / `src/lib/learning.ts` | Learning 项目列表、详情、媒体和下载结果的共享 DTO/Renderer facade | 项目 API 访问走固定 Host API;Renderer 不持有 Token、任意归档 URL 或本地路径,README 仅接受无凭据 HTTPS 图片 URL | -| `electron/api/routes/learning.ts` / `electron/services/learning-project-download.ts` | Main-owned Learning 项目代理、封面/历史媒体读取和原生 ZIP 保存 | 固定 Works 路径、严格 DTO 与媒体 MIME/大小边界、最多五跳同源重定向、SHA-256/ZIP 签名校验和原子重命名;归档不做大小校验 | -| `src/pages/Learning/` / `src/components/layout/LearningSidebar.tsx` | 分页项目卡片、README 详情和下载入口 | 保留登录与 `module_access.learning`;README 禁用原始 HTML,仅图片节点直连无凭据 HTTPS URL,旧生成/播放器入口不存在 | | `src/pages/AiHardware/index.tsx` | Robot 管理、现有六位 Binding,以及已实现的 default-on 引导式热点配网状态机 | 只编排非敏感步骤;不接收 Wi-Fi 密码,不把 `bound` 展示为在线证明 | | `src/lib/ai-hardware.ts` | Renderer 侧 Robot Host API 类型、安全错误映射和稳定 Binding/hotspot facade | 读取 Main-owned capability,调用固定 portal-open,并只传递短效 hotspot candidate ID;不添加任意 URL、SSID 或 Renderer IPC | | `electron/api/routes/ai-hardware.ts` | Main-owned Robot 云端代理,以及本地 capability/portal/hotspot actions | 默认开启、精确环境值 `0` 回滚;所有本地操作必须在 Works token/上游访问前返回,且只投影稳定安全错误 | @@ -63,7 +60,6 @@ - Built artifact preflight 检查最终上传的同字节快照,但客户端可被绕过且不产生可信 receipt;服务端仍是合同、摘要和不可变 Release 安全权威。 - Robot Renderer → typed AI hardware API → Main Host route → Robot Hotspot Module → Windows/macOS Adapter。云端 Binding 仍由 Main 代理;热点选择/连接移入页面,但家庭 Wi-Fi 凭据输入仍只留在固件 Portal,系统 Wi-Fi 保留为兜底。 - Prompt Museum Renderer → typed Host API facade → Main fixed list/detail route → Works Square。Museum 只把用户明确选择的 Prompt 原文暂存到进程内 Store 并导航回当前 Canvas;不会直接触发 Agent 命令或生成任务。 -- Learning Renderer → typed Host API → Main fixed project routes → Works Square list/detail/media/archive。Main 代理受控图片并持有原生保存与归档校验;Renderer 只获得安全 DTO、图片数据和保存结果。 ## Risky Or Sensitive Areas @@ -83,7 +79,6 @@ - Design V2 事件处理必须区分 Direction projection 与 Workspace Task/Asset projection;不得用 transport sequence 或 Task progress 推进 Specification revision。 - 输入、Quote request、confirmation 和项目切换跨 Renderer/Main/Works Square。异步结果必须核对 Workspace、Direction、revision 与 operation identity;unknown write 复用原 identity,不能生成新业务意图。 - Prompt Museum 图片和来源 URL 来自服务端数据。服务端必须完成内容授权/署名审核;若未来需要凭据化素材,应新增 Main-owned 媒体代理,不能把对象存储凭据放进 Renderer URL。 -- Learning 的远端 JSON、Markdown、媒体、错误和 ZIP 下载跨信任边界;必须保持严格 DTO、固定项目/封面/历史媒体路径、可信 raster MIME、媒体/README 大小、同源重定向、SHA-256、ZIP 签名、一次 401 refresh 和固定安全错误。ZIP 下载明确不校验 `Content-Length`、`archiveBytes`、实际流字节数或客户端大小上限。README 仅允许图片节点直连经服务端校验的无凭据 HTTPS URL,不能扩展为任意 Works 下载或文件系统代理。 - Design command replay requires the original stable command and semantic operation IDs; business rejection never authorizes a retry with a fresh identity. Resource reconciliation cannot overwrite the Living Form. - Canvas cleanup only closes the local stream/buffers. The server-owned Direction Session and semantic history remain durable. - Image/video references use typed Asset binding and Specification medium; display text is never an action protocol. diff --git a/.project-docs/20-architecture/system-overview.md b/.project-docs/20-architecture/system-overview.md index 63e5e82..7f3bf93 100644 --- a/.project-docs/20-architecture/system-overview.md +++ b/.project-docs/20-architecture/system-overview.md @@ -13,7 +13,7 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 | Project Configuration & Template | 保存不可变 `ProjectType`,原子生成新项目骨架 | 小游戏/小程序生成受控 Vite 模板;自定义保持最小项目空间 | | Project Release Builder | Main-owned 安全快照、本地 npm/Vite 构建、双归档与 artifact contract | 固定 npm 11.6.2;Vite 由项目 lockfile 锁定;产物与预检使用同一内存字节 | | Works Session & Remembered Password | Main-owned 登录、刷新、注销、七天真实活动滑动续期与可选密码回填 | 登录、刷新、注销统一经过 Works Square;轮换凭据由 Main 安全持有和持久化。记住密码使用独立的 packaged-only OS 加密记录,不进入 Renderer 持久状态或 Works Square;客户端不携带 OAuth client secret | -| Module Access Policy | Main-owned `/api/auth/me` projection → Renderer auth state → module chooser/router | Renderer 只接收 Code/Canvas/Learning/Robot 四个布尔值;缺失对象或字段默认开启,服务端 `design` 映射客户端 `painting` | +| Module Access Policy | Main-owned `/api/auth/me` projection → Renderer auth state → module chooser/router | Renderer 只接收 Code/Canvas/Robot 三个布尔值;缺失对象或字段默认开启,服务端 `design` 映射客户端 `painting`,额外旧字段被忽略 | | Submission Binding | 保存云端已接受的精确 app/version/review/hash 绑定 | schema v2 只记录成功提交;旧中间态迁移为 `legacy_retired`,不恢复后台任务 | | Play URL Projection | 校验服务端公共播放地址 | 只接受同源 HTTPS 和精确 `/apps/{app_id}/`;优先 `play_url`,`runtime_url` 仅一版本回退 | | Built Artifact Preflight | 在上传前对最终 built snapshot 做桌面/移动 UX fail-fast | Main 使用临时 loopback origin 与 Electron WebContents/CDP;不使用 Playwright、不生成可信证明 | @@ -31,9 +31,6 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 | AI Design Quote & Task Controls | 精确 Specification revision 编译出的不可变 Quote 与 Workspace Tasks | 客户端只展示 public output summary、warnings、expiry 与 Token Points,并以 Quote ID 确认;Provider Prompt/model/route/storage/billing atoms 不下发 | | AI Design Assets | Workspace 已完成作品或本地上传的真实 Asset | Asset 通过 typed binding 写入 Specification;生成结果经 Main-owned asset download 保存 | | Prompt Museum | Canvas “获取灵感”页面 → Main Host API → Works Square | 服务端驱动的审核内容、筛选和分页;Renderer 不持有 Works Token 或内置数据集,“使用此 Prompt”只回填输入框 | -| Learning Project Catalog | 服务端分页项目卡片、README 详情与用户选择的 ZIP 下载 | Renderer 只持有安全项目 DTO 和 Markdown;不提供生成、进度、本地课程库或播放器 | -| Learning Main Boundary | 固定 Host API → Works Square project list/detail/media/archive | Main 持有 Works Bearer、封面/历史媒体代理、原生保存路径、受控重定向、临时文件、SHA-256/ZIP 签名校验与原子落盘;归档下载不执行大小校验,README 图片节点可直接加载无凭据 HTTPS URL,Renderer 不获得任意归档 URL 或本地路径 | - | Robot Workspace | Account-scoped agent configuration, device activation/binding, assignment, and credential-recovery UI | Renderer receives only safe Works Square projections. Configuration choices come from the USER-scoped safe catalog; unavailable current values remain editable without exposing provider credentials or configuration internals. | | AI Hardware Main Route | Fixed `/api/works/ai-hardware` Host API to Works Square proxy | Main owns Bearer auth, stable operation IDs, bounded retry, ETag/If-Match, request/response limits, error redaction, and the fixed no-store configuration-catalog proxy. Versioned responses accept only canonical strong or weak numeric ETags that equal the DTO revision; mutations always emit strong `If-Match`. It never forwards Renderer authorization headers. | | Robot Hotspot Module | Main-owned bounded scan/connect/verify interface with Windows WLAN and macOS CoreWLAN/CoreLocation adapters | Accepts only short-lived opaque candidates from the latest scan, exposes no BSSID/interface/profile/native diagnostic, and retains the system-Wi-Fi fallback. | @@ -43,13 +40,15 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 - Renderer 只能通过 Main Host API 发起认证操作。Electron Main 是 access/refresh token 的唯一客户端所有者;登录、刷新、注销统一经过 Works Square 固定路由,客户端不得直连 one-feel/custom 身份服务,也不得保存 confidential OAuth client secret。 - 可选的记住密码记录属于 Electron Main 的独立本机边界,只能在正式安装包且 OS 凭据加密可用时落盘;Renderer 不得持久化账号密码,Works Square 不得接收记住标志或新增密码持久化。 -- Code、Canvas、Learning 与 Robot 是四个已启用顶层产品模块;Robot 仍是唯一硬件产品模块,不存在单独 Hardware 卡片。 -- 每个登录用户可由 Works `module_access` 关闭任意顶层模块入口。Main 只投影四个布尔值;被关闭卡片置灰不可点,根/深层/别名路由在 `MainLayout` 和模块初始化前拦截。Code provider 必须等待 auth policy hydration,而全局 `/settings` 不属于 Code policy guard。 +- Code、Canvas 与 Robot 是三个已启用顶层产品模块;Robot 仍是唯一硬件产品模块,不存在单独 Hardware 卡片。 +- 每个登录用户可由 Works `module_access` 关闭任意顶层模块入口。Main 只投影三个布尔值;被关闭卡片置灰不可点,根/深层/别名路由在 `MainLayout` 和模块初始化前拦截。Code provider 必须等待 auth policy hydration,而全局 `/settings` 不属于 Code policy guard。 - 模块入口策略是用户交互与客户端初始化边界,不是服务端 API 授权。终止性 `/api/auth/me` `401` 必须清理 Main 和 Renderer 会话;各模块 API 仍须独立强制认证与授权。 - Product UI language is Chinese-only. Unsupported system or persisted language values normalize to `zh`; removed locale files are not runtime fallbacks. - Canvas is cloud-only in development and packaged builds. The removed local Workspace adapter and V1 DTOs are not fallback paths. - Prompt Museum is a read-only curated inspiration surface, not a user-content community. Main owns Works authentication and forwards only the bounded list/detail routes; the client never bundles museum content or automatically submits a selected Prompt. -- Learning uses a Main-owned read-only project-catalog boundary. Renderer does not receive Works credentials, object-storage identifiers, arbitrary archive URLs, temporary/final paths, or an arbitrary network proxy. README raw HTML is disabled; Markdown image nodes alone may load validated credential-free HTTPS URLs directly, including SVG and other formats Electron supports. Main continues to validate covers/historical media and streams a selected project ZIP through bounded same-origin redirects, SHA-256, ZIP signature, and atomic rename before returning only `saved` or `cancelled`; it ignores archive size metadata and applies no client download-size ceiling. +- Learning is retired. No Learning route, Renderer surface, Main Host API, download service, + shared DTO, packaging asset, or fallback remains. Historical downloaded course data is + not read, migrated, or automatically deleted. - AI hardware network access is Main-owned. Renderer cannot hold Works Square or Xiaozhi credentials and cannot select arbitrary upstream paths or headers. - Robot model, language, and voice choices are dynamically projected from the Xiaozhi USER catalog through Works Square and Electron Main; the catalog is bounded, account-scoped, and `private, no-store` at each public hop. - One local account maps to one server-side Xiaozhi account binding. Agents and devices are resources beneath that account binding, not separate Xiaozhi users. @@ -97,7 +96,7 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 ## Related Decisions -- 当前长期边界记录于 README、ADR-002 至 ADR-007(ADR-001 已由 ADR-007 supersede)及各 Integration Gate;后续如改变唯一入口、凭据所有权、构建执行边界、Living Form/Specification 状态归属、Pi 单 runtime/worker/lease 边界、Learning/Marketplace/Web Search 分发边界、Robot 配网边界或重新引入客户端部署协调器,应新增 ADR。 +- 当前长期边界记录于 README、ADR-002 至 ADR-007(ADR-001 与 ADR-005 已 supersede)及各 Integration Gate;后续如改变唯一入口、凭据所有权、构建执行边界、Living Form/Specification 状态归属、Pi 单 runtime/worker/lease 边界、Marketplace/Web Search 分发边界、Robot 配网边界、重新引入 Learning 或重新引入客户端部署协调器,应新增 ADR。 ## Last Updated diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md index e1a1872..4692e43 100644 --- a/.project-docs/30-worklog/current-state.md +++ b/.project-docs/30-worklog/current-state.md @@ -4,6 +4,22 @@ This file is the integrated default-branch snapshot. Feature tasks record progre ## Integrated Through +- Learning-removal source `5a7cb9b2085848631bdf7de45fe1cac74b905ed9` from task + `20260903-remove-learning-7a91` is integrated onto the latest local `main` as + `bd0873f34823754760368d8d37703c74bf65106d` by task + `20260904-integrate-remove-learning-6e4a9c21`, preserving the later AI Design + and unified Plugin workspace changes. Makelore now exposes only Code, Canvas, + and Robot; the Learning route tree, Renderer, Main Host API, download service, + shared DTOs, artwork, tests, server contract, and packaging surface are removed. + Stale Learning URLs return to the module chooser and retired Host API paths use + the standard not-found boundary. Historical course data remains untouched and + unread, while `makelore-learning:v1` stays byte-for-byte frozen only as a shared + account-partition compatibility salt. ADR-005 and the Learning deployment + commitment are superseded by the user's explicit 2026-09-04 product decision. + Integration verification passed dependency lock install, 8 focused files / 109 + tests, typecheck, full unit 1,844 with 3 skips plus pressure 1/1, production build, + targeted Electron 8/8, and full Electron 33/33 with 1 platform skip. Lint reported + 0 errors and only existing warnings in current source and a historical worktree. - Conversation-first AI Design client source `fe50e4ba198649a07c5c9443f15edf7dfa2a47e6` from task `20260903-design-guided-conversation-client-8b4e1c72` is integrated onto local @@ -87,8 +103,9 @@ This file is the integrated default-branch snapshot. Feature tasks record progre local and remote mainline histories with normal merge `d25ed08`: fetched `origin/main` frontier `28e1690` and local frontier `301c149` are both ancestors of the integrated tree. It also reviewed every local worktree/branch; historical - Learning Player source `b1f51be` remains intentionally excluded under ADR-005, - rather than being mistaken for current product work. No history rewrite or + Learning Player source `b1f51be` remains intentionally excluded and the later + complete Learning removal supersedes ADR-005, rather than that source being + mistaken for current product work. No history rewrite or force-push is part of this integration. - Packaged Pi runtime-root source `5d7a235` from task `20260902-build-unsigned-mac-9d7e4a2c` is integrated through merge `4babd6d`. @@ -147,8 +164,8 @@ This file is the integrated default-branch snapshot. Feature tasks record progre and collapsed process previews remain anchored to their first displayable line. Those predecessor Project Configuration Plugin surfaces are superseded by the reviewed unified `/plugins` workspace above and are not restored. Historical - source `b1f51be` remains explicitly excluded because - ADR-005 continues to retire the OpenMAIC Learning Player packaging chain. + source `b1f51be` remains explicitly excluded; the 2026-09-04 product decision + supersedes ADR-005 and removes the remaining Learning catalog surface as well. - Youth-facing AI Design client source `0fd32a2d49045a8f9e7f2e19ba6477f48f93e30c` is integrated over Model Tools frontier `7552cf59526449c29d663a769c0fb62d84a1a759` through merge @@ -479,14 +496,15 @@ This file is the integrated default-branch snapshot. Feature tasks record progre their existing boundaries. The matching Works Square source is `65ea070`. - Learning project-catalog source commit `38db158` from feature task `20260819-learning-project-catalog-impl-4e9c71a2` was merged as `d967b0f` by integration task - `20260820-integrate-learning-catalog-a73e91c4`. Learning keeps its login and + `20260820-integrate-learning-catalog-a73e91c4`. At that historical checkpoint Learning kept its login and `module_access.learning` gate but now contains only a server-driven project list, safe README detail, direct credential-free HTTPS Markdown images, and a Main-owned verified native ZIP save path. Course generation, progress, local library, OpenMAIC player, Agent/ASR/classroom runtime, Learning IPC and player-artifact packaging were removed without a compatibility read path. Historical course data is left untouched. The matching Works Square operations/admin/API implementation and real-account package - smoke remain pending. + smoke remained pending. The 2026-09-04 removal recorded at the top of this file + supersedes that catalog and its deployment obligation. - Square-auth lifecycle source commit `dc776ff` from feature task `20260819-square-auth-proxy-client-8c4f2a` was merged as `f52c2c8` and promoted from verified candidate `e7ec12d` to local `main` by integration task @@ -521,7 +539,7 @@ This file is the integrated default-branch snapshot. Feature tasks record progre - Project-cover source commit `145a6ce571d646325092d1e722282babea503954` from feature task `20260817-project-cover-upload-a6a98e56`, integrated by task `20260813-sync-push-main-9c2f71`. First submission now requires a bounded PNG/JPEG/WebP cover, shows preview/file/reselect feedback, and sends metadata plus cover through Main-owned `POST /api/projects/with-cover`; conflicts stop before version upload and existing draft/published projects remain version-only. The matching Works Square server source is `407c883` (local merge `0cedfc4`). No client package, production deployment, or real-account smoke occurred. - `3b37ac3` / `55e61b7`: macOS Robot hotspot discovery performs one bounded worker-thread rescan after an empty or SSID-redacted CoreWLAN result; persistent SSID redaction maps to the existing permission error instead of a misleading empty-device state, while firmware and the open `Xiaozhi-*` contract remain unchanged. - `f5d47c8` / `b6148a5`: AI Programming voice capture is available after an Agent is selected but before the lazy first OpenCode session exists; transcription fills the composer draft without creating an empty session, while runtime, loading, transcribing, busy, and recording guards remain unchanged. -- `4013edc` / `3b799af`: integrated per-user Code/Canvas/Learning/Robot entry policy from Works Square, projected by Electron Main as four booleans and enforced before disabled module routes initialize. +- `4013edc` / `3b799af`: historically integrated per-user Code/Canvas/Learning/Robot entry policy from Works Square. The 2026-09-04 removal narrows the current Main projection to Code/Canvas/Robot while preserving the same pre-initialization route gate. - `01bee31`: historically enabled the AI Learning course catalog/generation/download/playback architecture. Its Learning course/runtime behavior is superseded by `38db158` above; its unrelated `game-engine` removal and project-root `planning-with-files` behavior remain historical context. - `26b52d7`: Canvas Prompt Museum, editable server-priced generation Quotes, project deletion/task-detail workflow, cloud-default Canvas development entry, and Chinese-only UI consolidation from the authoritative remote main. Its transient bundled `game-engine` Skill is superseded by `01bee31`. - `f8d82e6`: Prompt Museum media rendering now accepts only the server-controlled relative media route, fetches it through a Main-owned bounded Works-authenticated proxy with one refresh retry, and keeps credential-free HTTPS CDN media direct. Renderer-side validation and card-local placeholders cover invalid or failed media; attribution URLs remain optional. @@ -555,11 +573,11 @@ Canvas 侧栏提供“获取灵感”进入 Prompt Museum。列表、筛选、 密码登录提供可选“记住密码”。该记录与七天登录会话分离,只在正式安装包且系统安全存储可用时由 Electron Main 加密落盘;Renderer 仅在登录页内存中接收回填,不写 Zustand/localStorage,Works Square 也不持久化桌面密码。退出登录和短信登录不删除记录,成功的未勾选密码登录会清除旧记录。未打包开发版禁用该选项,避免未签名 Electron 调试进程触发 macOS 钥匙串。 -Makelore 在会话恢复、登录和刷新后由 Electron Main 请求 Works `/api/auth/me`,Renderer 只获得 Code、Canvas、Learning、Robot 四个布尔权限。缺失 `module_access` 或任一字段时默认开启;服务端 `design` 显式映射客户端 `painting`。被关闭的模块卡片置灰且不可点击,根路由、深层路由和别名路由均在 `MainLayout` 或模块初始化前阻断。Code provider 等待认证权限加载完成;权限查询返回终止性 `401` 时同时清理 Main 和 Renderer 会话。`/settings` 是全局设置,不受 Code 入口策略阻断。该机制只是客户端入口策略,不代替服务端 API 授权。 +Makelore 在会话恢复、登录和刷新后由 Electron Main 请求 Works `/api/auth/me`,Renderer 只获得 Code、Canvas、Robot 三个布尔权限。缺失 `module_access` 或任一字段时默认开启;服务端 `design` 显式映射客户端 `painting`,额外旧字段被忽略。被关闭的模块卡片置灰且不可点击,根路由、深层路由和别名路由均在 `MainLayout` 或模块初始化前阻断。Code provider 等待认证权限加载完成;权限查询返回终止性 `401` 时同时清理 Main 和 Renderer 会话。`/settings` 是全局设置,不受 Code 入口策略阻断。该机制只是客户端入口策略,不代替服务端 API 授权。 插件在编程侧栏只有一个“插件”入口,`/plugins` 是唯一产品页面,并以统一列表投影 Marketplace、账号 Library、官方设备状态、本机 Device Packages、当前项目状态与 retained IDs;旧 `/plugin-marketplace`、`/my-plugins` 与 `/project-plugins` 路由只做确定性筛选重定向。获取、设备安装、项目启用、Agent Skill 分配、运行授权和计费仍是独立生命周期,不因界面统一而自动推进;原生 selected-model Web Search 不进入插件列表。 -AI 学习现在是已启用的运营精选项目目录,并继续受登录和 `module_access.learning` 控制。Renderer 通过 Main-owned Host API 获取分页项目卡片和 README 详情;Markdown 支持 GFM、禁用原始 HTML。服务端发布时只校验图片 URL 为无凭据、默认端口、无 fragment 且当前 DNS 结果全部为公网地址的 HTTPS URL,保留地址而不下载、识别格式、转码或镜像;客户端仅为 README 图片节点启用直连,因此 SVG 和 Electron 支持的其他格式可直接显示,单图失败不阻断详情。封面和历史发布媒体继续走受控路径。详情页的下载按钮打开系统保存对话框;Main 将 ZIP 流式写入临时文件,只允许最多五跳同 Works origin 重定向,不校验 `Content-Length`、`archiveBytes`、实际流字节数或客户端大小上限,校验 SHA-256 和 ZIP 签名后原子保存,Renderer 只接收 `saved` 或 `cancelled`。课程生成、进度、本地课程库、OpenMAIC player、Agent、ASR、课堂 runtime、Learning IPC 和 player artifact 打包已删除且没有兼容读取路径;历史课程数据保留但不再读取。服务端和客户端源码契约已完成,不代表生产部署或真实账号安装包联调已经完成。 +Learning 已从 Makelore 产品中移除:没有模块卡片、路由、侧栏、Renderer 页面、Main Host API、下载服务、共享 DTO、素材或打包 fallback。旧 Learning URL 回到模块选择页,旧 API 使用标准 404 边界。客户端不扫描、读取、迁移或自动删除历史课程数据;名称仍含 Learning 的冻结账号分区盐仅为跨模块持久状态兼容标识,不代表产品模块仍存在。 AI 编程已经硬切到精确 pin 的 Pi `0.84.2`,不存在 OpenCode fallback 或双 runtime。project、Agent 与 Conversation 只在 `.makelore/project.json` 和 `.makelore/conversations.json` 使用本地 schema v2;当前客户端不从 `.niancode` 或 `.opencode` 读取、迁移或删除项目元数据。Electron Main 按需启动一个长驻父 Agent Server,每条 active/warm Conversation 在其中拥有独立 Runtime/Session/channel、credential store、extension context、generation/seq、Snapshot/Patch、model/thinking、队列、interaction 与错误状态,Composer 在 lazy prepare 期间仍可编辑;正式包从 staged `pi-runtime` manifest/root 定位并校验 Pi 包入口,不从脚本目录或应用 `node_modules` 回退。Renderer 只通过 `/api/coding/*` 和 Snapshot-first/`patch-batch` SSE 消费产品中立合同;gap/reconnect 只恢复目标 Conversation,accepted/uncertain mutation 不自动重放。`lifecycle:sleep` 会关闭旧事件流,视图挂载、项目上下文变化、页面重新可见或窗口 focus 会静默刷新已选 Snapshot,使后台 terminal 状态收敛且不重放 mutation。隐藏 Conversation 的红点只在新 pending interaction 或新 completed/failed/aborted terminal transition 出现,不由助手/thinking/工具过程或单个工具失败触发。Session hydration 沿完整 active branch 投影可见历史,Pi compaction 只改变模型上下文并保持 summary 私有;Renderer 首次挂载最近 120 个节点,向上滚动时按 100 个节点追加更早内容并补偿新增高度以保持阅读锚点。折叠的 thinking、助手过程说明与工具输出固定展示第一条可见内容和首个非空行,横向偏移保持为零,展开后仍显示完整内容。未解析 Conversation 第一次选模先 validate 并持久化 resolved metadata,再 prepare;同账号模型切换使用 target `set_model`,跨账号只重建目标逻辑线程。Web Search 只作为所选模型 capability 进入父 worker 并使用相同 model/provider/credential 与普通模型计费,不经过 Marketplace/Hosted client/Plugin Charge 或浏览器 fallback;child 不继承。Conversation 工具可检查并在独立确认后安装 npm、Git、本地 Plugin 目录或 loose `SKILL.md` 为 Main-owned immutable Device Package;不运行生命周期脚本,可执行 extension 具有桌面用户权限,新/idle parent 自动刷新,active parent 结算后刷新,child 为空。每个 Main-selected generation 都保留全部显式安装且当前启用的 Skill 路径;生成的 Makelore bridge 是必需的首个 extension,其余 extension 全部经 `additionalExtensionPaths` 加载,并继续关闭 ambient discovery。top-level 逻辑 turn 并发为 4,warm idle logical-thread LRU 为 8;independent child 进程并发为 4 并使用 FIFO 进程预算 8;coding child 与 parent 共用项目 write lease。同一助手工具批次内,内置 `bash`/`edit`/`write` 与声明需要该 write lease 的动态产品工具按顺序执行,避免 Pi 在批量 prepare 阶段形成租约自锁;纯只读工具批次仍可并行。prompt/compact confirmation timeout 后仍保留 run/Agent-Server-or-child-process/background ownership,迟到 success/failure/exit/abort 单调且 exactly-once 收敛,页面隐藏不会停止 active/uncertain run。线程级替换只使目标 generation 失效;整个 Agent Server 退出会统一使所有旧 channel 失效,但 Main/Renderer 存活且下次恢复只重启一个 Server。Pi `0.84.2` 手动 compact 不发 `agent_settled`,由 correlated compact RPC 结果终结。父 Provider credential 只进入选中逻辑线程的内存 credential store,child 凭据只进入该短命进程;确定性的 Works user-context 缺失会失效 gateway credential、fail fast、不重放并投影固定 Provider-auth 错误,不归类为 Pi crash。精确的 `403 token_point_balance_exhausted` / `词元点数余额不足` 上游响应会在 Main 内归类为不可重试的配额耗尽,Renderer 只收到稳定 `CODING_PROVIDER_QUOTA_EXHAUSTED` 与安全中文提示;重开会话仍保留该提示,不暴露上游 request id 或原始错误正文。真实 Provider 验证仍为用户明确接受的未验证风险;macOS arm64 仅有本地未签名 mounted-image initialize/shutdown 证据,签名/公证/完整 process-enumeration、macOS x64 与 native non-WSL Linux 仍未通过平台发布门禁。 @@ -569,6 +587,12 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选 ## Recently Completed +- 2026-09-04: Integrated the complete Learning removal onto the latest local `main`. + The module chooser, guarded routes, sidebar, auth projection, Main dispatcher, + lifecycle state, build output, and docs now contain only Code, Canvas, and Robot. + Stale Learning routes return to the chooser, the retired API is unregistered, and + historical course data remains untouched. Full unit, build, and Electron E2E gates + passed on the integrated tree. - 2026-09-03: Reconciled local `main@7a37593` with fetched `origin/main@8e947b4` through a normal two-parent merge. The result keeps the reviewed single `/plugins` workspace and Device Package multi-extension @@ -663,10 +687,9 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选 ## Next Recommended Steps 1. 在停止服务的目标数据库完成 Design V2 cutover dry-run、清零 blocker、显式 apply/validate,再用成对部署的服务端与安装包真实账号执行 direct edit、chat edit、Quote request/confirm、后台完成、结果下载和 interrupted unknown-result replay smoke; paid Provider activation 另行授权。 -2. 部署 Works `module_access` migration 与 `/api/auth/me` 权限 API,打包新 Makelore 客户端,再用真实账号分别关闭 Code、Canvas、Learning、Robot 执行卡片、根/深层/别名路由 smoke;同时独立验证模块 API 服务端授权。 -3. 部署 Works Square Learning 项目管理、对象存储、README HTTPS URL 校验和 list/detail/media/archive API,再使用真实账号验证发布/下架、分页、远程 README 图片(含 SVG、失效 origin 和隐私提示)、ZIP 校验/保存以及 Windows 与签名 macOS 安装包。 -4. 对 default-on Guided Hotspot Binding 核对指定固件镜像与六位码发行/消费契约,补齐 Windows 真机热点连接、签名 macOS x64/arm64 CoreWLAN/CoreLocation/worker 打包验证、真实 Host API/native seam Electron E2E 和完整真机 smoke;发布支持保留精确 `=0` 回滚,不把缺失证据表述为已验收。 -5. 成组核对客户端 source+built+contract 上传 → 服务端逐字节校验 → OSS immutable Release → CDN/Edge 的发布契约与客户端 `play_url` 消费契约。 +2. 部署 Works `module_access` migration 与 `/api/auth/me` 权限 API,打包新 Makelore 客户端,再用真实账号分别关闭 Code、Canvas、Robot 执行卡片、根/深层/别名路由 smoke;同时独立验证模块 API 服务端授权。 +3. 对 default-on Guided Hotspot Binding 核对指定固件镜像与六位码发行/消费契约,补齐 Windows 真机热点连接、签名 macOS x64/arm64 CoreWLAN/CoreLocation/worker 打包验证、真实 Host API/native seam Electron E2E 和完整真机 smoke;发布支持保留精确 `=0` 回滚,不把缺失证据表述为已验收。 +4. 成组核对客户端 source+built+contract 上传 → 服务端逐字节校验 → OSS immutable Release → CDN/Edge 的发布契约与客户端 `play_url` 消费契约。 ## Open Questions / Blockers @@ -674,9 +697,9 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选 ## Risky Areas -- 四模块权限只控制 Makelore 客户端入口和初始化,不是 API 授权边界。不得因卡片置灰或路由阻断而放宽 Works/模块服务端的身份与权限校验;旧服务端缺少对象/字段时默认开启是显式兼容策略。 +- 三模块权限只控制 Makelore 客户端入口和初始化,不是 API 授权边界。不得因卡片置灰或路由阻断而放宽 Works/模块服务端的身份与权限校验;旧服务端缺少对象/字段时默认开启是显式兼容策略。 - Prompt Museum 相对媒体必须保持固定的服务端路径并由 Main 处理;如果服务端增加媒体变体,需同步维护 entry/path 语法、Works Bearer 所有权、单次刷新、10 MiB 限制、可信 raster MIME 白名单与 Renderer data URL 校验。HTTPS 直连媒体必须继续无凭据,图片失败必须局限在卡片/详情视图。 -- Learning 项目目录依赖真实 Works 运营发布和固定 API 契约;README 图片只允许服务端校验后的无凭据 HTTPS URL,并由 Markdown 图片节点直接加载,不得扩展为任意网络或归档代理。第三方 origin 的可用性、格式支持和请求隐私是已接受边界。Main 必须保持 Bearer 所有权、封面/历史媒体受控读取、一次 401 refresh、同源最多五跳、SHA-256/ZIP 签名和原子保存;客户端下载明确不执行大小门禁,不得把上游错误、Token、对象存储 URL 或本地路径投影到 Renderer。历史课程数据不再读取但也不得被隐式删除。 +- Learning 必须保持完全退役;不得因旧链接、旧服务端字段或历史数据而恢复入口、路由、API、下载或打包 fallback。历史课程数据不再读取、迁移,也不得被隐式删除。 - Works Project 首次封面已由服务端源 `407c883`(本地 merge `0cedfc4`)提供单请求原子绑定与失败补偿,客户端源 `145a6ce` 因此要求首次发布上传 PNG/JPEG/WebP 封面;部署、安装包和真实账号/对象存储 smoke 仍未完成。服务端仍没有已有 metadata 的 revision/ETag 与 draft-only 条件写,因此已有 draft/published 继续只允许 version-only,客户端不得以无条件 PATCH 替代。 - Guided Hotspot Binding 默认开启并提供未经认证的热点扫描/显式连接,但当前 Hotspot/portal 仍是开放 SoftAP + 明文 HTTP,且精确出货镜像、激活码发行契约、签名 macOS、Windows 真机与完整整链尚未验证。界面必须保留环境警告,异常发布可用精确环境值 `0` 回滚;不得把 SSID 前缀宣称为可信设备发现、自动认领或在线证明。 - 一键提交已成功但本机 submission binding 落盘失败时必须保持提交成功、显示固定 `binding_warning` 并继续轮询,避免用户误判上传失败。 diff --git a/.project-docs/30-worklog/tasks/20260904-integrate-remove-learning-6e4a9c21.md b/.project-docs/30-worklog/tasks/20260904-integrate-remove-learning-6e4a9c21.md new file mode 100644 index 0000000..d2c7ee5 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260904-integrate-remove-learning-6e4a9c21.md @@ -0,0 +1,99 @@ +# Task: Integrate Learning module removal + +## Identity + +- Task ID: 20260904-integrate-remove-learning-6e4a9c21 +- Mode: Integration +- Branch: main +- Worktree: /Users/inmanx/Documents/makelore +- Base commit: bdc290c2cffee6eebbc1b1a6826edad3f830572a +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Integrate source task `20260903-remove-learning-7a91` at commit + `5a7cb9b2085848631bdf7de45fe1cac74b905ed9` onto the latest local `main`. +- Preserve later `main` work, especially the conversation-first AI Design changes, while + removing the Learning module from Renderer, Main, tests, assets, packaging, and docs. +- Promote the accepted three-module product boundary into canonical project memory and + supersede the Learning-specific decision and release commitments. +- Verify the merged product on the latest `main`, then start the desktop development app. + +## Intent And Constraints + +- The user explicitly confirmed on 2026-09-04 that Learning should remain removed; this + is the human decision required to supersede ADR-005. +- Keep Code, Canvas, Robot, authentication, updater, Plugins, packaging, and + `/deliverables` behavior intact. +- Do not inspect, migrate, or delete historical course data. +- Preserve the literal `makelore-learning:v1` compatibility salt where it partitions + shared account state; its name is not an active Learning product surface. +- Do not restore a Learning fallback route. Stale Learning URLs should resolve through + the normal module chooser behavior. +- Source task records are read-only during integration. + +## Project Context Loaded + +- Concurrent Task Gate passed in Integration mode on the root `main`; this task held + the exclusive integration lock and the completed source task remained isolated. +- Planning Gate passed after reading the source record, integrated current state, + ADR-005, decision index, architecture, domain rules, success criteria, + commitments, and stale registry. +- The user's explicit 2026-09-04 confirmation resolves the semantic conflict with + accepted ADR-005 and authorizes the three-module canonical product state. +- `project-positioning.md` remains a project-wide placeholder; the populated README, + AGENTS guidance, decisions, architecture, domain, and current-state records provide + the concrete product boundary for this integration. + +## Outcome + +- Cherry-picked source `5a7cb9b2085848631bdf7de45fe1cac74b905ed9` + onto the latest local `main` as `bd0873f34823754760368d8d37703c74bf65106d`. +- Resolved README and Plugin Marketplace E2E conflicts by preserving the newer + unified Plugin workspace and AI Design behavior while removing Learning policy data. +- Makelore now exposes only Code, Canvas, and Robot across module selection, guarded + routing, sidebar projection, auth state, desktop lifecycle, Host API dispatch, and + build output. +- Promoted the accepted three-module boundary into canonical decisions, architecture, + domain rules, success criteria, glossary, current state, and commitments. ADR-005 + and the Learning deployment commitment are superseded. +- Learning implementation files, routes, DTOs, assets, tests, and server contract stay + deleted. Negative retirement tests and the frozen `makelore-learning:v1` shared + account-partition salt are the only intentional implementation references. +- Historical course data was neither inspected nor changed. + +## Verification + +- `corepack pnpm install --frozen-lockfile` passed with pnpm `10.33.4`. +- `corepack pnpm run typecheck` passed. +- Focused Vitest passed 8 files / 109 tests covering module access/navigation, + App/provider gates, auth route/store, lifecycle, Main layout, and Host dispatch. +- `corepack pnpm run lint:check` completed with 0 errors and 12 existing warnings: + 5 in current unchanged UI files and 7 under the historical + `.worktrees/learning-player-release-fix` tree. +- `corepack pnpm run build:vite` passed for Renderer, Main, Preload, and release + utility. The output contains Code, Canvas, and Robot module assets and no + Learning-named asset or chunk. +- `corepack pnpm test` passed 220 files / 1,844 tests with 3 skips; the isolated + pressure suite passed 1/1. +- Targeted Electron E2E passed 8/8 across module navigation, retired Learning route, + unified Plugins, and the latest Canvas flow. +- Full Electron E2E passed 33/33 with 1 Windows-conditional skip. +- Source/reference audit found no active Learning file, import, route registration, + API constant, product copy, or build artifact. `git diff --check` passed. +- Project-doc completeness and task-aware Integration Gate drift checks passed after + confirming the source task record remained byte-identical in its source branch. + +## Follow-ups + +- The existing real-account module-access release commitment now covers Code, + Canvas, and Robot only. No Learning service deployment or package acceptance remains. +- No remote push, packaged-client install, production deployment, or publication was + performed by this integration. + +## Promotion Candidates + +- Applied all source promotion candidates to canonical decisions, architecture, + domain rules, success criteria, glossary, current state, and commitments. No + unresolved Learning promotion candidate remains. diff --git a/.project-docs/40-domain/business-rules.md b/.project-docs/40-domain/business-rules.md index c6f3541..2e62381 100644 --- a/.project-docs/40-domain/business-rules.md +++ b/.project-docs/40-domain/business-rules.md @@ -79,7 +79,7 @@ - `works-cloud-deploy.json` 仅是已安装客户端的数据兼容文件名,不表示客户端仍提供 cloud deployment coordinator。 - Works Square 会话按真实键盘、鼠标或触摸活动滑动续期,连续 7 天未使用才要求重新授权。 - “记住密码”是独立于七天会话的可选桌面凭据记录:只能由 Electron Main 在正式安装包中通过可用的系统安全存储加密落盘,账号密码不得进入 Renderer 持久状态、日志或 Works Square 持久化。退出登录和短信登录保留记录;只有成功的未勾选密码登录清除旧记录。系统安全存储不可用或未打包开发版必须禁用该选项。 -- 运营端可按用户关闭 Code、Canvas、Learning 或 Robot 客户端入口,默认全开。Makelore 通过 Main-owned `/api/auth/me` 只消费四布尔安全投影;缺失 `module_access` 或字段按开启处理,服务端 `design` 对应现有客户端 `painting`。 +- 运营端可按用户关闭 Code、Canvas 或 Robot 客户端入口,默认全开。Makelore 通过 Main-owned `/api/auth/me` 只消费三布尔安全投影;缺失 `module_access` 或字段按开启处理,服务端 `design` 对应现有客户端 `painting`,额外旧字段被忽略。 - 关闭的模块卡片必须置灰且无法点击;其根路由、深层路由和别名路由必须在 `MainLayout` 或模块初始化前阻断。Code provider 只能在 auth policy hydration 完成且 Code 已开启时初始化;`/settings` 是全局设置,不得随 Code 关闭而失去访问。 - 模块置灰/路由阻断不是 API 授权边界。每个 Works/模块服务端 API 仍必须独立执行身份与权限检查;`/api/auth/me` 返回终止性 `401` 时必须清理 Main 和 Renderer 会话,不得以默认全开继续。 - Makelore Code 的唯一 production runtime 是精确 pin 的 Pi `0.84.2`。不得恢复 OpenCode fallback、RPC/SDK 双轨、兼容执行路径或 Renderer runtime 直连;产品公共合同必须保持 project/Agent/Conversation/Snapshot/Patch 中立,Pi wire 只属于 Main。 @@ -109,11 +109,7 @@ - Prompt Museum 只展示服务端审核并带稳定作者、来源、许可证/署名和素材授权信息的内容;客户端不内置数据集、不推断缺失版权、不提供投稿/点赞/评论/收藏/关注/排行,也不自动发送或生成选中的 Prompt。 - Prompt Museum Renderer 只能通过 Main-owned 固定 GET 路由读取列表和详情。Works Token 不得进入 Renderer;列表查询只允许搜索、使用场景、风格、主体、语言、模型、游标和限制字段。需要受保护图片时必须新增 Main-owned 媒体代理,不能把 access token、内部路径或用户隐私放进图片 URL。 - 产品界面当前只支持中文;系统语言与历史持久设置中的其他值必须归一为 `zh`,不得保留不可达的伪语言选择。 -- Learning 是登录必需且受 `module_access.learning` 控制的运营精选项目目录。客户端只提供项目列表、README 详情和用户触发的 ZIP 下载,不提供课程生成、进度、本地课程库、OpenMAIC 播放、Agent、ASR 或课堂 runtime,也不保留旧 API/数据兼容读取路径。 -- Works Square 运营后台拥有项目名称、摘要、封面、标签、版本、排序、状态、Markdown、ZIP、声明字节数和 SHA-256。只有冻结了元数据、媒体和归档校验结果的 `published` 项目可被客户端列表或详情读取;客户端不提供上传、编辑或发布入口。 -- README 使用安全 Markdown/GFM 并禁用原始 HTML。运营发布时必须解析 Markdown,只保留无凭据、默认端口、无 fragment 且当前 DNS 全部指向公网地址的 HTTPS 图片 URL;不得下载、识别格式、转码或镜像 README 图片。客户端仅允许 Markdown 图片节点直连这些 URL,支持范围和可用性由 Electron 与远程 origin 决定;封面和历史发布媒体继续走受控媒体路径。 -- Main 打开原生保存对话框,将项目 ZIP 下载流写入临时文件,只允许无 userinfo、最多五跳且保持 Works origin 的重定向,重定向请求不携带 Bearer;客户端下载不校验 `Content-Length`、`archiveBytes`、实际流字节数或大小上限,只在 SHA-256 与 ZIP 签名匹配后原子重命名。Works Square 可保留独立的运营上传/存储限制。Renderer 不得获得 Works Token、对象存储地址、任意下载 URL、临时路径或最终本地路径。 -- 新客户端不读取、迁移或展示历史下载课程,但也不自动删除用户现有课程数据。任何清理功能必须另行设计为用户明确控制的可恢复维护动作。 +- Learning 已从产品入口、路由、Renderer、Main Host API、共享 DTO 和打包资源中移除,不保留兼容实现。客户端不读取、迁移或展示历史下载课程,也不自动删除用户现有课程数据;任何清理功能必须另行设计为用户明确控制的可恢复维护动作。 - `game-engine` 不再是内置 Coding Skill。`planning-with-files` 在复杂任务中把 `task_plan.md`、`findings.md`、`progress.md` 写到当前项目根目录;不得写入 Skill 安装目录或用户目录。 - Robot V1 在现有 Binding 体验内扫描符合条件的开放 `Xiaozhi-*` 配网热点,并只连接用户明确选择的短效候选;该便利信号不得宣称为可信设备发现、自动下发家庭 Wi-Fi、自动认领或自动确认在线。 - Guided Hotspot Binding capability 由 Electron Main 持有且默认开启。精确 `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` 关闭引导;关闭或 capability 读取失败时保留现有六位码 Binding,Renderer 可以读取但不能覆盖它。 @@ -137,8 +133,7 @@ - AI Canvas V2 仍需安装包真实账号 smoke:direct edit、chat edit、Quote request、confirmation、background completion、result download 与 deliberately interrupted unknown-result retry;生产 Provider 激活需另行授权。 - Robot Guided Hotspot Binding default-on 发布仍需确认指定硬件/固件确实提供被审计的开放 Hotspot/Portal、部署端签发严格六位 ASCII 数字码且与 Works validator 的时效/消费语义一致,并完成 Windows 真机、签名 macOS x64/arm64 native worker/association 与真实设备端到端 smoke。 - Prompt Museum 客户端契约和页面完成不等于 Works Square 内容后台、审核数据或 CDN 已部署;上线前需以真实登录账号验证列表、详情、署名链接、分页、图片和 Prompt 回填。 -- Learning 客户端集成不等于 Works Square 运营后台、对象存储或项目 API 已部署;上线前需先部署服务端合同,再以真实账号验证发布/下架、分页、远程 HTTPS README 图片(含 SVG 与失败隔离)、保存取消、缺失或错误 `Content-Length` 的 ZIP、摘要/签名错误以及 Windows/签名 macOS 安装包。 -- 四模块入口策略上线前需确认 Works `module_access` migration/API 已部署,安装包包含对应客户端,并用真实账号逐一关闭四个模块验证卡片、根/深层/别名路由和独立 API 授权。 +- 三模块入口策略上线前需确认 Works `module_access` migration/API 已部署,安装包包含对应客户端,并用真实账号逐一关闭 Code、Canvas、Robot 验证卡片、根/深层/别名路由和独立 API 授权。 - Makelore Code 已有 Windows x64 最终安装包证据、WSL2/WSLg Linux 证据,以及 macOS arm64 本地未签名 DMG 的 mounted-image Agent Server initialize/shutdown 证据。macOS arm64 的签名/公证与完整 process-enumeration gate、macOS x64、native non-WSL Linux desktop/compositor 仍未验收;在补齐前不得宣称 cross-platform release-ready。真实 Provider 风险虽经用户明确豁免,但仍不得从 loopback/provider-shaped smoke 推断为真实 Provider Pass。 ## Last Reviewed diff --git a/.project-docs/40-domain/glossary.md b/.project-docs/40-domain/glossary.md index 5075c50..91db85a 100644 --- a/.project-docs/40-domain/glossary.md +++ b/.project-docs/40-domain/glossary.md @@ -19,10 +19,6 @@ | Design Generation Quote | 服务端对当前最终 Prompt 与 generation parameters 返回的可确认报价 | 客户端可编辑后请求重报价,但不自行计算尺寸、供应商价格或设计点 | | Prompt Museum | Canvas 的服务端驱动、只读策展灵感模块 | 展示经审核的预览、Prompt、分类和署名;不是社区,客户端不打包内容数据集 | | Pending Museum Prompt | 用户点击“使用此 Prompt”后在 Renderer 进程内保存的一次性回填状态 | Canvas 消费后清除;只填充输入框,不自动发送或生成 | -| Learning Project | Works Square 运营发布、供用户阅读 README 并下载源码/素材 ZIP 的精选学习项目 | 客户端只读;不是课程、生成任务或本地播放器实体 | -| Published Learning Project | 在一个发布代际中冻结名称、封面、Markdown、镜像媒体、ZIP 字节数和 SHA-256 的公开项目快照 | 只有 `published` 状态进入客户端列表/详情;下架后不可见,审计记录保留 | -| Learning Project Media | README 远程 HTTPS raster 图片经服务端校验、镜像后生成的固定项目媒体资源 | 客户端只接受固定 `/api/learning/projects/:projectId/media/:mediaId` 路径;Main 验证可信 MIME 和 10 MiB 上限 | -| Learning Project Archive | 用户在详情页明确选择保存的项目 ZIP | Main 负责原生保存窗口、受控下载、SHA-256、ZIP 签名和原子重命名;不按大小字段或客户端上限阻断,也不在客户端形成课程库 | | Robot Provisioning | 让 Robot 获得目标 Wi-Fi 凭据并尝试联网的阶段 | 与云端 Activation、账号 Binding、协议在线是不同阶段;V1 由现有固件 Hotspot portal 完成 | | Robot Activation | Robot 联网后向既有服务获取六位激活码的阶段 | 激活码由设备展示/播报给用户;Makelore 不生成该码 | | Robot Binding | 用户把六位激活码和 Agent 提交到 Works Square,建立账号侧设备关系 | `bound` 不等于设备当前 online 或 protocol-ready | diff --git a/.project-docs/80-commitments/commitments.md b/.project-docs/80-commitments/commitments.md index a9d5e47..c79db24 100644 --- a/.project-docs/80-commitments/commitments.md +++ b/.project-docs/80-commitments/commitments.md @@ -7,8 +7,8 @@ Track future-facing memory: promised follow-ups, unfinished loops, timed checks, | 2026-08-21 | 验收修复后的真实本地 OpenCode Session 模型切换 | OpenCode runtime 尚存时 | 客户端/OpenCode 集成 | Superseded / Closed | Pi hard cutover 已删除该 runtime 与 native Session model wire;不得再执行或恢复此验收。当前 Conversation 模型规则由 ADR-006、schema v2 与 target Pi worker contract 约束。 | | 2026-08-19 | 验收 AI Design 有限请求生命周期并决定正式 HTTP/2 策略 | 发布包含源 `87e4140` 的正式安装包前 | 客户端/Works 服务端/发布集成 | Pending | 使用真实账号连续执行 Quote PATCH、重试报价与继续调整,制造或等待慢响应,确认 30 秒内稳定返回、其他请求不被共同挂住、服务端无隐式重复 mutation;随后恢复 HTTP/2 重新打包做同场景对照。只有证据确认后才能移除临时 `disable-http2` 或把协议策略固化为正式决定。 | | 2026-08-17 | 验收 AI 编程真实 Provider 双 Conversation 行为 | 未来撤销用户豁免,或对外宣称真实 Provider 并发/凭据隔离已经验证前 | 客户端/Pi/provider 集成 | Explicitly Waived / Accepted Risk | 当前 `realTurnVerified=false`。若重新开启验收,必须覆盖真实认证、endpoint/proxy/rate-limit、四协议响应与图片差异、真实并发或串行、abort/event/session/model/credential 跨 worker 隔离;provider-shaped loopback 与 4+4 本地 PID 证据不能替代该验证。 | -| 2026-08-17 | 验收用户级四模块入口策略的真实发布链 | 发布包含源 tip `3b799af` 的 Makelore 安装包前 | Works 服务端/客户端/发布集成 | Pending | 部署 `module_access` migration 和 `/api/auth/me` API,重新打包并安装 Makelore;使用真实账号依次关闭 Code、Canvas、Learning、Robot,重启后验证卡片置灰/不可点、根/深层/别名路由阻断、`/settings` 仍可访问和 terminal `401` 退出;另行以直接 API 请求确认服务端授权,不以 UI 置灰代替 | -| 2026-08-20 | 部署并验收 AI Learning 项目目录完整链路 | 发布包含源 `38db158`、`9956739`、`8509084` 的 Makelore 安装包前 | Works 运营后台/服务端/客户端/发布集成 | Pending | 实现并部署项目管理、对象存储、发布代际、README HTTPS URL 校验、list/detail/media/archive API;以真实账号验证发布/下架、排序分页、README/GFM/远程图片(含 SVG、失效 origin 和请求隐私说明)、原生保存取消、同源重定向、缺失或错误 `Content-Length` 时仍可下载、SHA-256/ZIP 签名失败和 Windows/签名 macOS 安装包。客户端下载不再验证 `archiveBytes`、实际字节数或大小上限;不得以客户端测试宣称运营后台或生产数据已上线 | +| 2026-08-17 | 验收用户级三模块入口策略的真实发布链 | 发布包含 Learning 移除集成 `bd0873f` 的 Makelore 安装包前 | Works 服务端/客户端/发布集成 | Pending | 部署 `module_access` migration 和 `/api/auth/me` API,重新打包并安装 Makelore;使用真实账号依次关闭 Code、Canvas、Robot,重启后验证卡片置灰/不可点、根/深层/别名路由阻断、`/settings` 仍可访问和 terminal `401` 退出;另行以直接 API 请求确认服务端授权,不以 UI 置灰代替 | +| 2026-08-20 | 部署并验收 AI Learning 项目目录完整链路 | Learning 已于 2026-09-04 从 Makelore 移除 | Works 运营后台/服务端/客户端/发布集成 | Superseded / Closed | 不再为 Makelore 客户端部署或验收 Learning 项目目录 API。历史课程数据保持不读、不迁移、不自动删除;如未来恢复 Learning,必须重新作产品决定和 ADR。 | | 2026-08-17 | 验收首次项目封面并补齐已有资料条件写 | 发布包含源 `145a6ce` 的 Makelore 前,或启用 draft/published metadata 编辑前 | Works 服务端/客户端发布集成 | Partial / Pending | 首次封面已由服务端源 `407c883` 与客户端源 `145a6ce` 通过单请求绑定和失败补偿落地;仍需先部署服务端、重新打包客户端,并以真实对象存储/账号验证 404→create 409、cover 失败和版本阻断。已有资料编辑继续等待 metadata revision/ETag 与 draft-only 条件写;在此之前保持 existing version-only。 | | 2026-08-16 | 验收 Prompt Museum 与 Canvas 删除/重报价的真实服务端链路 | 发布包含 `26b52d7` Canvas 行为的安装包前 | 客户端/服务端集成 | Pending | 使用真实 Works 账号核对 Museum 列表/详情/分页/筛选/署名/CDN/Prompt 回填,并确认固定相对媒体路径可经 Main Bearer 代理、401 刷新、10 MiB/可信 raster MIME 边界后在 Renderer 展示;核对最终 Prompt/options 重报价和确认设计点;删除 Workspace 后确认软删除可见性、未提交任务取消/预留积分释放、已运行任务结算。保留 Main 错误脱敏和严格 DTO/HTTPS 投影,不以客户端回归替代服务端验收 | | 2026-08-16 | 验收 default-on Robot Guided Hotspot Binding 的 Windows/macOS 真实设备链路 | 下一份包含页面内热点连接行为的安装包发布前 | 客户端/硬件/服务端集成 | Pending | 核对精确出货固件与固定 Portal、六位码发行/消费语义;执行 Windows Robot 真机扫描/连接、签名 macOS x64/arm64 CoreLocation/CoreWLAN/worker/ASAR/Koffi smoke,以及真实 Host/native Electron 端到端配网+Binding;保留 `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` 回滚并记录支持矩阵 | From 2d3c103bac83fccffb15b760a6e79912c352541a Mon Sep 17 00:00:00 2001 From: inman Date: Fri, 4 Sep 2026 14:10:59 +0800 Subject: [PATCH 04/22] docs: record remote main fetch --- .../tasks/20260904-pull-main-c7e2b4.md | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .project-docs/30-worklog/tasks/20260904-pull-main-c7e2b4.md diff --git a/.project-docs/30-worklog/tasks/20260904-pull-main-c7e2b4.md b/.project-docs/30-worklog/tasks/20260904-pull-main-c7e2b4.md new file mode 100644 index 0000000..d162984 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260904-pull-main-c7e2b4.md @@ -0,0 +1,65 @@ +# Task: Pull latest main into local repository + +## Identity + +- Task ID: 20260904-pull-main-c7e2b4 +- Mode: Feature +- Branch: main +- Worktree: /Users/inmanx/Documents/makelore +- Base commit: 5f87a42d598914cf644d8c388ec39da238e9c8a2 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Fetch `origin` and synchronize the local `main` branch with the current remote + `main` while preserving the three existing local commits. +- Report whether the histories are already aligned, fast-forwardable, or divergent. + +## Intent And Constraints + +- Keep the integrated three-module product state and the local Learning-removal + commits intact. +- Do not rebase, reset, force-push, or discard local work. +- Use a normal non-interactive merge only if the fetched remote has diverged; stop + for semantic conflicts rather than choosing a product direction automatically. +- Do not modify application behavior as part of repository synchronization. + +## Outcome + +- Fetched `origin` successfully and advanced `origin/main` from `4032361` to + `336e0bb`. +- Determined that local `main` and `origin/main` diverged from `4032361`: local + had 3 commits and remote had 5 commits. +- The five remote commits are scoped to AI Design stream/progress/Quote handoff + code, tests, and canonical project docs. They do not modify active Learning + implementation paths, but their base snapshot predates the local Learning removal. +- Deferred the history merge to a dedicated Integration task because merging the + remote canonical documents requires serialized reconciliation. + +## Verification + +- `git fetch --prune origin` completed successfully. +- `git rev-list --left-right --count main...origin/main` returned `3 5`. +- Merge base is `403236115bf81e7617856cd8219b1be23f6abbb8`. +- Remote frontier is `336e0bb0caf24537b7b0f350aba3e04a37f5544c`; local frontier + at fetch time is `5f87a42d598914cf644d8c388ec39da238e9c8a2`. +- Remote diff inspection found only AI Design implementation/tests and its project + documentation; no new remote Learning implementation change was introduced by + the five commits. + +## Follow-ups + +- Start an Integration task, merge `origin/main` normally, preserve both histories, + keep the local three-module product boundary, resolve canonical document overlap, + and verify the resulting tree. + +## Promotion Candidates + +- Target: the next Integration task and `current-state.md`. Proposal: record the + merged remote frontier `336e0bb` while keeping local Learning removal + `bd0873f` and documentation integration `5f87a42`. Evidence: fetched graph and + diff inspection. Future impact: latest AI Design fixes and the three-module + product must coexist. Semantic conflict: remote canonical docs predate Learning + removal. Human confirmation: already supplied by the current three-module + AGENTS instruction and the earlier explicit Learning-removal decision. From 533f8bb4576dec88bafb9be02988897df07fb46c Mon Sep 17 00:00:00 2001 From: inman Date: Fri, 4 Sep 2026 14:16:55 +0800 Subject: [PATCH 05/22] docs: record remote main integration --- .project-docs/30-worklog/current-state.md | 18 +++- .../20260904-merge-remote-main-91c4e7.md | 94 +++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 .project-docs/30-worklog/tasks/20260904-merge-remote-main-91c4e7.md diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md index 3cc8b69..d32c2ba 100644 --- a/.project-docs/30-worklog/current-state.md +++ b/.project-docs/30-worklog/current-state.md @@ -4,6 +4,22 @@ This file is the integrated default-branch snapshot. Feature tasks record progre ## Integrated Through +- Remote `main` frontier `336e0bb0caf24537b7b0f350aba3e04a37f5544c` + is integrated with local frontier + `2d3c103bac83fccffb15b760a6e79912c352541a` by the ordinary two-parent merge + `2d0322ef7a98c99fb28de4902506ea135ccd9389` under task + `20260904-merge-remote-main-91c4e7`. The remote AI Design streamed-reply, + pending-chat, and Quote handoff changes are retained, while the local Learning + removal remains authoritative: the active product still exposes only Code, + Canvas, and Robot. A matching `design.assistant.delta` may appear only as one + provisional assistant bubble for its pending chat and is replaced by the + canonical turn; the removed generic organizing-progress banner stays absent. + Remote source task records remain in their source-parent history rather than being + copied into this integration result. Verification passed frozen-lockfile install, + 35 focused tests, typecheck, 1,845 full-suite tests with 3 conditional skips plus + the pressure test 1/1, production build, and targeted Electron 5/5. Lint reported + 0 errors and 12 existing warnings. No remote push, deployment, publication, or + packaged-app lifecycle change was made. - Learning-removal source `5a7cb9b2085848631bdf7de45fe1cac74b905ed9` from task `20260903-remove-learning-7a91` is integrated onto the latest local `main` as `bd0873f34823754760368d8d37703c74bf65106d` by task @@ -609,7 +625,7 @@ This file is the integrated default-branch snapshot. Feature tasks record progre AI Design Canvas 现在默认以对话和一张持续可见的“我的创作”卡服务 8-16 岁创作者;专业字段矩阵收进按需打开的“精细调整”,移动端保持对话优先并只挂载一个卡片/底部面板。Creation Card、精细调整、Quote、Task 与结果提示都只投影权威状态,已知问题按 code 转成通俗中文,未知服务端或 Provider 文本不会直接显示。一个 Workspace 仍只公开一个 current Direction、一个 persistent Agent Session 和一个 Current Specification;conversation timeline 只记录交互历史。Chat、direct edits、decision responses、proposal acceptance、locks、Asset binding 与 restore 都通过 `design.input.apply` 进入同一服务端 reducer,Renderer drafts 在 accepted 前保持本地。Main 持有 Works Token、stream ticket、WebSocket、request deadline、stable command/operation IDs 与错误脱敏;unknown result 只能复用原 identity,结构化业务错误不得重放。Generation 由服务端对 exact Specification revision 编译 immutable Quote,客户端只展示 public output plan、warnings、expiry 与 Token Points,并以 Quote ID 调用 `design.generation.confirm`;Provider Prompt、model、route、storage 和 billing atoms 不进入 Renderer。Task/Asset events 独立收敛 Workspace resources,不改写 Living Form。Development 与 packaged builds 均使用 Works Square V2,V1 DTO、local semantic adapter、mutable Quote PATCH 与 editable provider Prompt 已移除。 -新提交的 Design chat 会立即从现有 pending operation 投影为带“发送中”或“正在确认”的临时用户气泡,服务端确认的 canonical turn 到达后再替换它;确定失败时原草稿重新出现在输入框。`design.assistant.delta` 只作为内部传输与收敛状态,不显示原始片段,也不在对话区生成独立整理进度栏;右侧 Current Specification 摘要继续承担“AI 当前听懂的内容”。重复连接和事件按 connection generation 与 `chunkIndex` 收敛。 +新提交的 Design chat 会立即从现有 pending operation 投影为带“发送中”或“正在确认”的临时用户气泡,服务端确认的 canonical turn 到达后再替换它;确定失败时原草稿重新出现在输入框。匹配该 pending chat 的 `design.assistant.delta` 可以临时合并显示为一个未完成的助手气泡,并在 canonical turn 到达后被替换;它不进入 conversation timeline,也不生成独立的“AI 正在整理你的想法”进度栏。右侧 Current Specification 摘要继续承担“AI 当前听懂的内容”。重复连接和事件按 connection generation 与 `chunkIndex` 收敛。 Canvas 侧栏提供“获取灵感”进入 Prompt Museum。列表、筛选、分页、详情、作者/来源/许可证和图片地址全部由服务端经 Main-owned Host API 提供,客户端不打包静态数据集;服务端相对媒体只允许固定 `/api/image-prompt-museum/{entry}/media/{thumbnail|number}` 形状,并由 Main 注入 Works Bearer、执行一次 401 刷新、可信 raster MIME 与 10 MiB 上限后转为 Renderer data URL;credential-free HTTPS CDN 图片保持直连。图片失败只显示卡片内占位,不阻断卡片或详情;缺少来源 URL 时显示纯文本。“使用此 Prompt”只把原文带回当前 Canvas 输入框,不自动发送。该模块不是投稿、点赞、评论或排行榜社区。客户端契约已就绪,但不据此宣称 Works Square 内容后台和生产数据已经部署。`pnpm run dev` 现在默认使用云端 Canvas 适配器,本地适配器只能通过显式开发命令启用;产品 UI 只保留中文。 diff --git a/.project-docs/30-worklog/tasks/20260904-merge-remote-main-91c4e7.md b/.project-docs/30-worklog/tasks/20260904-merge-remote-main-91c4e7.md new file mode 100644 index 0000000..20b0784 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260904-merge-remote-main-91c4e7.md @@ -0,0 +1,94 @@ +# Task: Merge fetched remote main + +## Identity + +- Task ID: 20260904-merge-remote-main-91c4e7 +- Mode: Integration +- Branch: main +- Worktree: /Users/inmanx/Documents/makelore +- Base commit: 2d3c103bac83fccffb15b760a6e79912c352541a +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Merge fetched `origin/main@336e0bb0caf24537b7b0f350aba3e04a37f5544c` + into local `main` with a normal two-parent merge. +- Preserve local Learning removal `bd0873f`, its canonical integration `5f87a42`, + and the fetch record `2d3c103` while accepting the five remote AI Design commits. +- Reconcile overlapping canonical AI Design documents without restoring the remote + branch's older four-module snapshot. +- Verify the merged tree without changing the independently running packaged app. + +## Intent And Constraints + +- The current AGENTS instruction and the user's prior explicit decision establish + Code, Canvas, and Robot as the only enabled modules. Learning must remain removed. +- Preserve the remote streamed-reply and Quote-handoff behavior, tests, and accepted + canonical facts because they do not conflict with the three-module direction. +- Do not rebase, reset, force-push, or discard either line of history. +- Source task records from the remote commits are read-only and remain available in + their source history; the integration result records source commits in its own task + record and canonical `Integrated Through` entry. +- No package, deployment, publication, or remote push is part of this pull. + +## Project Context Loaded + +- Concurrent Task Gate passed in Integration mode on local `main`; this task owns the + worktree and exclusive integration lock at base `2d3c103`. +- Planning and Integration Gates passed after reading the fetch task, remote source + task outcomes and promotion candidates, integrated current state, decision index, + system overview, business rules, commitments, stale registry, and reflection index. +- No other local task is active. Remote changes are limited to AI Design + stream/progress/Quote presentation, tests, and related canonical documents. +- The remote branch's Learning content is inherited unchanged from the old common + base, not a new product decision. The current three-module AGENTS instruction and + prior human confirmation resolve that semantic conflict in favor of keeping + Learning removed. + +## Outcome + +- Created ordinary two-parent merge + `2d0322ef7a98c99fb28de4902506ea135ccd9389` with parents + `2d3c103bac83fccffb15b760a6e79912c352541a` and + `336e0bb0caf24537b7b0f350aba3e04a37f5544c`. +- Accepted the remote AI Design pending-chat, streamed assistant reply, and inline + Quote handoff implementation and tests. +- Preserved the local Learning removal. The merged product still exposes only Code, + Canvas, and Robot, and no Learning source, route, API, DTO, or packaged asset was + restored. +- Resolved the sole textual merge conflict in `current-state.md` by retaining both + the authoritative Learning-removal record and the remote AI Design history, then + reconciled its older streamed-reply wording with the merged implementation. +- Read all five remote source task records from the source parent and left them in + that parent history rather than copying foreign task records into this integration + result. +- Left the independently running packaged Makelore app untouched. No remote push, + deployment, publication, or package lifecycle action was performed. + +## Verification + +- `corepack pnpm install --frozen-lockfile` — passed with the pinned pnpm 10.33.4. +- `corepack pnpm run typecheck` — passed. +- `corepack pnpm exec vitest run tests/unit/image-canvas-page.test.tsx tests/unit/image-workspace-store.test.ts tests/unit/youth-creation-card.test.tsx` + — 3 files and 35 tests passed. +- `corepack pnpm run lint:check` — passed with 0 errors and 12 existing warnings, + including warnings from the historical `.worktrees/learning-player-release-fix`. +- `corepack pnpm run build:vite` — passed for Renderer, Main, Preload, and utility + worker bundles. +- `corepack pnpm test` — 220 files and 1,845 tests passed with 3 conditional skips; + the serialized pressure test then passed 1/1. +- `corepack pnpm run test:e2e -- tests/e2e/image-workspace-v2.spec.ts tests/e2e/main-navigation.spec.ts` + — targeted Electron verification passed 5/5, including AI Design production, + the three-module chooser, and retired Learning-route redirection. + +## Follow-ups + +- None recorded. + +## Promotion Candidates + +- Integrated `current-state.md`: record the remote frontier, merge identity, + preserved three-module boundary, AI Design behavior, and verification evidence. +- Integrated `current-state.md`: reconcile the provisional assistant reply wording + with the actual merged Renderer behavior. From 53f3db3aced61533944eab5e6b3c8c2293f733f8 Mon Sep 17 00:00:00 2001 From: inman Date: Sat, 5 Sep 2026 12:37:55 +0800 Subject: [PATCH 06/22] docs: record repository pull handoff --- .../tasks/20260905-pull-repository-7c4e9a.md | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .project-docs/30-worklog/tasks/20260905-pull-repository-7c4e9a.md diff --git a/.project-docs/30-worklog/tasks/20260905-pull-repository-7c4e9a.md b/.project-docs/30-worklog/tasks/20260905-pull-repository-7c4e9a.md new file mode 100644 index 0000000..55f99b8 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260905-pull-repository-7c4e9a.md @@ -0,0 +1,66 @@ +# Task: Pull repository updates + +## Identity + +- Task ID: 20260905-pull-repository-7c4e9a +- Mode: Feature +- Branch: main +- Worktree: /Users/inmanx/Documents/makelore +- Base commit: 533f8bb4576dec88bafb9be02988897df07fb46c +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Fetch the configured `origin` remote and determine whether local `main` can be + synchronized by fast-forward or requires a serialized integration. +- Preserve all local commits and uncommitted user work while reporting the exact + ahead/behind relationship. + +## Intent And Constraints + +- Do not rebase, reset, force-push, stash, or discard either history. +- Use a normal two-parent merge only from a dedicated Integration Gate task when + the histories have diverged and canonical project documents overlap. +- Keep the current three-module product boundary; do not restore Learning while + synchronizing the repository. + +## Outcome + +- Fetched `origin` successfully and advanced the remote-tracking `main` reference + from `336e0bb` to `d642d76`. +- Determined that local `main@533f8bb` and `origin/main@d642d76` diverge from + merge base `336e0bb`: local has 6 exclusive commits and remote has 9. +- Remote changes add the interactive AI project scaffold and bundled official + plugin status integration. Both histories modify canonical project documents, + and the product trees overlap in the shared Sidebar, so synchronization is + transferred to a serialized Integration Gate instead of being merged here. + +## Verification + +- `git fetch --prune origin` completed successfully. +- `git rev-list --left-right --count main...origin/main` returned `6 9`. +- `git merge-base main origin/main` returned + `336e0bb0caf24537b7b0f350aba3e04a37f5544c`. +- Diff-path inspection confirmed that the local side owns the complete Learning + removal while the remote side adds the project-scaffold plugin; both sides also + update canonical project documentation. + +## Follow-ups + +- Start a dedicated Integration Gate on the current worktree, merge + `origin/main` normally, reconcile canonical documentation without restoring + Learning, and verify local/remote ancestry plus the resulting worktree. + +## Promotion Candidates + +- Target: the Integration Gate task and canonical project memory. Proposal: + integrate remote frontier `d642d76` while preserving local frontier `533f8bb` + and the three-module product boundary. Evidence: fetched commit graph and + path-level comparison. Future impact: the official project-scaffold plugin and + bundled-status correction must coexist with the local Learning removal. + Semantic conflict: shared canonical documents were edited on both histories; + application changes appear complementary but require merge verification. + Human confirmation: the requested repository pull authorizes synchronization, + while the current AGENTS product boundary already requires Learning to remain + retired. From 8064add2420e5a2f571c1bb99e64589b66473c75 Mon Sep 17 00:00:00 2001 From: inman Date: Sat, 5 Sep 2026 12:44:50 +0800 Subject: [PATCH 07/22] docs: reconcile remote scaffold integration --- .project-docs/30-worklog/current-state.md | 21 ++++- ...04-project-scaffold-main-merge-e7b4c291.md | 42 --------- .../20260905-integrate-remote-main-b83d6f.md | 91 +++++++++++++++++++ ...gin-bundled-status-integration-8f2c5a41.md | 60 ------------ README.md | 4 +- 5 files changed, 113 insertions(+), 105 deletions(-) delete mode 100644 .project-docs/30-worklog/tasks/20260904-project-scaffold-main-merge-e7b4c291.md create mode 100644 .project-docs/30-worklog/tasks/20260905-integrate-remote-main-b83d6f.md delete mode 100644 .project-docs/30-worklog/tasks/20260905-plugin-bundled-status-integration-8f2c5a41.md diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md index 2bc12e0..1874c2a 100644 --- a/.project-docs/30-worklog/current-state.md +++ b/.project-docs/30-worklog/current-state.md @@ -4,6 +4,25 @@ This file is the integrated default-branch snapshot. Feature tasks record progre ## Integrated Through +- Remote `main` frontier `d642d7607c26dee01ef65b4e70dd756465dea16a` + is integrated with local frontier + `53f3db3aced61533944eab5e6b3c8c2293f733f8` by the ordinary two-parent merge + `a2cb07fedcf22bb14141a27cee2218c149b8c63e` under task + `20260905-integrate-remote-main-b83d6f`. The remote ADR-008 interactive AI + application type, bundled `makelore.project-scaffold` Skill, and bundled-delivery + status correction are retained alongside the local Learning removal: the product + still exposes only Code, Canvas, and Robot. Three canonical-document conflicts were + reconciled semantically, the application tree merged without content conflicts, + and README now describes minimal project creation plus explicit scaffold execution. + Verification passed 228 focused Vitest tests, the 12-test scaffold suite with the + canonical macOS temporary path, typecheck, 1,859 full-suite tests with 3 conditional + skips plus pressure 1/1, production build, and targeted Electron 1/1. Lint reported + 0 errors and 12 existing warnings. The default macOS `/var` temporary-path alias + still makes two copied-script scaffold tests exit before emitting JSON; the same + tests pass through the canonical `/private/var` path and remain a harness/path + portability follow-up. The two remote task records stay intact in remote parent + `d642d76` rather than being copied into this integration result. No push, package, + deployment, publication, or production database change was made. - Remote `main` frontier `336e0bb0caf24537b7b0f350aba3e04a37f5544c` is integrated with local frontier `2d3c103bac83fccffb15b760a6e79912c352541a` by the ordinary two-parent merge @@ -825,4 +844,4 @@ Robot 绑定设备默认先显示“引导配网 / 已有激活码”路径选 ## Last Updated -2026-09-04 +2026-09-05 diff --git a/.project-docs/30-worklog/tasks/20260904-project-scaffold-main-merge-e7b4c291.md b/.project-docs/30-worklog/tasks/20260904-project-scaffold-main-merge-e7b4c291.md deleted file mode 100644 index 51b812c..0000000 --- a/.project-docs/30-worklog/tasks/20260904-project-scaffold-main-merge-e7b4c291.md +++ /dev/null @@ -1,42 +0,0 @@ -# Task: Land official project scaffold plugin on main - -## Identity - -- Task ID: 20260904-project-scaffold-main-merge-e7b4c291 -- Mode: Integration -- Branch: main -- Worktree: D:\Datas\OthersProjects\makelore -- Base commit: 336e0bb0caf24537b7b0f350aba3e04a37f5544c -- Owner: codex -- Status: Ready for Integration - -## Scope - -- Fast-forward the verified integration commit `b92e7ba5bbde186626d93787ca358fbf4c2523a3` onto local `main`. -- Record final default-branch landing evidence while preserving the three explicitly adopted historical task documents unchanged and uncommitted. - -## Intent And Constraints - -- Preserve the accepted official bundled-plugin boundary: only the fixed code-owned resource root may execute `scaffold.mjs`; downloadable Marketplace artifacts continue to reject scripts. -- Preserve normal ancestry and do not package, publish, deploy, push, or alter the adopted historical task documents. - -## Outcome - -- Fast-forwarded local `main` from `336e0bb0caf24537b7b0f350aba3e04a37f5544c` to verified integration head `b92e7ba5bbde186626d93787ca358fbf4c2523a3`. -- Local `main` now contains the unified `interactive_ai_app` type and the official bundled Marketplace Plugin `makelore.project-scaffold`, including its Skill, templates, publication guidance, and `scaffold.mjs`. -- Preserved the three explicitly adopted historical task documents byte-for-byte and left them uncommitted. -- Excluded the completed source integration task record from the final default-branch tree; it remains reachable on its preserved source branch and commit. - -## Verification - -- The fast-forward completed without conflicts and `b92e7ba5bbde186626d93787ca358fbf4c2523a3` is an ancestor of local `main`. -- The exact landed integration head had already passed Plugin and Skill validators, 88 focused Vitest tests, 12 scaffold Node tests, TypeScript typecheck, Git whitespace checks, and project-document gates. -- The task-aware drift check passed while confirming all adopted historical documents remained unchanged. - -## Follow-ups - -- Packaging, installed-client smoke, live Marketplace publication, deployment, and remote push remain separate authorized operations. - -## Promotion Candidates - -- None. This task landed the already accepted and verified integration result. diff --git a/.project-docs/30-worklog/tasks/20260905-integrate-remote-main-b83d6f.md b/.project-docs/30-worklog/tasks/20260905-integrate-remote-main-b83d6f.md new file mode 100644 index 0000000..2429126 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260905-integrate-remote-main-b83d6f.md @@ -0,0 +1,91 @@ +# Task: Integrate remote main into local main + +## Identity + +- Task ID: 20260905-integrate-remote-main-b83d6f +- Mode: Integration +- Branch: main +- Worktree: /Users/inmanx/Documents/makelore +- Base commit: 53f3db3aced61533944eab5e6b3c8c2293f733f8 +- Owner: codex +- Status: Transferred to Final Integration Gate + +## Scope + +- Merge fetched `origin/main@d642d76` into local `main@53f3db3` with a normal + two-parent merge and preserve both histories. +- Reconcile overlapping product and canonical documentation so the official + Project Scaffold plugin coexists with the locally integrated Learning removal. +- Verify ancestry, repository integrity, and focused behavior affected by any + conflict resolutions. + +## Intent And Constraints + +- Hold the Integration Gate lock for the complete merge and documentation + reconciliation. +- Do not rebase, reset, force-push, stash, discard local commits, or publish the + result remotely. +- Preserve the product boundary of Code, Canvas, and Robot only; do not restore + Learning routes, APIs, assets, or packaging paths. +- Accept ADR-008's already-reviewed `interactive_ai_app` and bundled official + Scaffold Skill boundary while retaining Main-owned build/release authority. +- Resolve semantic conflicts from source task outcomes and accepted decisions; + do not choose whole-file `ours` or `theirs` for canonical documents. + +## Outcome + +- Created ordinary two-parent merge + `a2cb07fedcf22bb14141a27cee2218c149b8c63e` with parents local + `53f3db3aced61533944eab5e6b3c8c2293f733f8` and remote + `d642d7607c26dee01ef65b4e70dd756465dea16a`; neither history was rewritten. +- Resolved the only three conflicts in `data-flow.md`, `system-overview.md`, and + `current-state.md`. The resulting canonical state keeps the three-module + Code/Canvas/Robot access projection and complete Learning removal while adopting + ADR-008, `interactive_ai_app`, the explicit bundled Project Scaffold Skill, and + its device-status correction. +- Application code, including the shared Sidebar, merged automatically without + conflict. Confirmed that no Learning implementation path was restored. +- Updated README to replace the obsolete automatic小游戏/小程序 template description + with minimal project creation, historical alias normalization, explicit scaffold + execution, and the code-owned bundled-script exception. +- Kept the two imported remote task records read-only and excluded them from the + resulting default-branch tree; both remain intact in remote parent `d642d76` and + its history, while their accepted outcomes are recorded in canonical current state. +- Local `main` is now 8 commits ahead and 0 behind `origin/main`. No push, package, + deployment, publication, or production database mutation was performed. + +## Verification + +- `git show -s --format='%H %P' a2cb07f` confirms the expected two parents, and + `git rev-list --left-right --count main...origin/main` returned `8 0` immediately + after the merge. +- Focused Vitest: 14 files, 228 tests passed. +- Scaffold Node tests: the default macOS `/var` temporary alias produced 10/12 with + two empty-output copied-script failures; rerunning through the canonical + `/private/var` temporary directory passed 12/12. The alias difference was confirmed + with `fs.realpathSync(os.tmpdir())` and did not require product-code conflict repair. +- `pnpm run typecheck` passed using pinned pnpm `10.33.4`. +- Full `pnpm test` passed 1,859 tests with 3 conditional skips, then pressure 1/1. +- `pnpm run lint:check` completed with 0 errors and 12 existing warnings. +- `pnpm run build:vite` completed all Renderer, Main, Preload, and utility builds. +- Targeted Electron E2E `project-configuration-skills.spec.ts` passed 1/1. +- Git whitespace and unresolved-conflict checks passed before the merge commit. +- The first task-aware drift check correctly blocked on the two task records imported + by the merge commit. It did not identify an authored mutation to either record; + final gate closure is transferred to a fresh Integration task whose base contains + the merged and reconciled history. + +## Follow-ups + +- Harden the scaffold CLI main-module check or its macOS test fixture so scripts + invoked through the `/var` alias match Node's canonical `/private/var` module URL; + this is not changed incidentally by repository synchronization. +- Deploy the matching Works Square server and migrate through + `20260904_project_scaffold_0085` before claiming production Marketplace availability. +- Close the project-document ownership check from fresh Integration task + `20260905-finalize-pull-integration-c49a71` based on the reconciled merge result. + +## Promotion Candidates + +- None. This Integration Gate applies the accepted ADR-008 and source-task outcomes + directly to canonical project memory. diff --git a/.project-docs/30-worklog/tasks/20260905-plugin-bundled-status-integration-8f2c5a41.md b/.project-docs/30-worklog/tasks/20260905-plugin-bundled-status-integration-8f2c5a41.md deleted file mode 100644 index cc189b8..0000000 --- a/.project-docs/30-worklog/tasks/20260905-plugin-bundled-status-integration-8f2c5a41.md +++ /dev/null @@ -1,60 +0,0 @@ -# Task: Integrate bundled official Plugin device-status fix - -## Identity - -- Task ID: 20260905-plugin-bundled-status-integration-8f2c5a41 -- Mode: Integration -- Branch: main -- Worktree: D:\Datas\OthersProjects\makelore -- Base commit: 9ed9bf4a715e300420e7a764a4cf576a6dfc4938 -- Owner: codex-root -- Status: Ready for Integration - -## Scope - -- Integrate feature commit `6bd9287c879dca93da11e17533f84e3535fc656a` - onto local `main` from its exact parent `9ed9bf4a715e300420e7a764a4cf576a6dfc4938`. -- Reconcile the source outcome into current state and remove only the duplicate source - task record from the default-branch checkpoint. - -## Intent And Constraints - -- Concurrent Task Gate: Passed after releasing the completed prior integration lock and - claiming this exact root/main worktree in Integration mode. -- Planning Gate: Passed after reading the source task outcome, ADR-008, current Plugin - architecture/business rules, current state, and the prior scaffold landing record. -- Preserve the three explicitly adopted historical task records byte-for-byte and - uncommitted; do not stash, reset, clean, move, or delete them. -- Preserve the accepted boundary: Project Scaffold ships in the signed client, has no - device download action, and still requires server-authoritative free account - acquisition before project enablement. - -## Outcome - -- Cherry-picked feature source `6bd9287c879dca93da11e17533f84e3535fc656a` - onto local `main` as product commit `6d102b2` without conflict. -- Removed only the duplicate source task record from the default-branch documentation - checkpoint; it remains intact on the source branch and commit. -- Updated current state with the accepted presentation correction and the separate - production server deployment/migration requirement. -- Preserved all three adopted historical task records byte-for-byte and uncommitted. - -## Verification - -- Source evidence: focused Plugin page/model 35 passed; typecheck and scoped ESLint - passed; all Vite targets passed; isolated Pi timing rerun 6/6 and pressure 1/1 passed. -- Source commit and landed product commit have the exact same tree - `1976d2f27f239dead6d26f99131182c7c657a9db`. -- Root replay was not claimed because the root checkout has no installed Vitest or - TypeScript executables; no dependency mutation was performed there. -- Local server evidence remains separate: Project Scaffold Marketplace tests 2/2 and - sole migration head `20260904_project_scaffold_0085`. - -## Follow-ups - -- Deploy the current Works Square server and migrate production through 0085 so the - public catalog can authorize and expose `免费获取`. - -## Promotion Candidates - -- None recorded. diff --git a/README.md b/README.md index 299500d..75e6478 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工 应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与三个模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 -作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“小游戏”“小程序”或“自定义项目”:小游戏和小程序会创建完整的平台发布模板,项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。 +作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“交互式 AI 应用”或“自定义项目”,创建服务只生成 `.makelore/project.json` 与 `knowledge/`;历史 `mini_game` / `mini_program` 配置在读取时归一为交互式 AI 应用,但不会因读取被改写。用户获取、启用并为智能体分配官方 bundled `makelore.project-scaffold` 插件后,可明确调用 `makelore-project-scaffold` Skill,以不覆盖既有路径的方式生成固定六文件 Vite 起步工程;该 Skill 不安装依赖、不联网、不构建、不上传或提审。交互式 AI 应用的项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。 ## 当前产品状态 @@ -29,7 +29,7 @@ Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工 - AI 绘画健壮性:Renderer 为每次命令生成稳定 operation id,网络结果未知时保留原命令供原样重试,不把未知写入当失败或创建第二次生成。Main 负责 Token 刷新、Agent Gateway REST 提交和有界 Run 查询,并将可恢复事件流投影为 Host API SSE;断线后按事件游标续接并重新读取权威 Workspace。Canvas 只使用 Works Square 云端 V2 契约,没有本地语义适配器或降级路径,上游不可用时明确报错。 - AI 绘画项目栏只展示 Workspace,不再在项目下创建独立设计会话。删除时必须完整输入项目名称;删除后项目、Living Form、任务、参考图和生成作品会从账户中隐藏且无法访问,不影响用户已另存到磁盘的副本。删除当前项目后自动打开最近更新的剩余项目,删除最后一个项目后进入空状态。 - 插件工作台:Code 侧栏只有一个“插件”入口,`/plugins` 在同一列表中投影官方目录、账号 Library、本机 Device Packages 与当前项目状态;旧 Marketplace、My Plugins、Project Plugins URL 只做确定性筛选重定向。来源身份保持 `official:`、`local:`、`retained:` 分离;“免费获取”、官方设备交付、项目启用和伙伴分配仍分别写入既有 authority,不会自动推进下一步。任一来源失败不会清空其他来源;本机 Skill/Pi 插件仍只能通过对话安装,页面只管理已安装包并明确其本机全局生效范围。 -- 官方插件运行架构:Renderer 只调用 Main-owned Marketplace facade;Main 负责账号、请求 deadline、签名/摘要校验、不可变 Release、current selection 与原子回滚。Pi parent worker 使用冻结的 effective snapshot,将每个有效 Skill 与已验证 Package Store root 成对传给 resource loader、Extension Host 和 CLI;`skill_only` 不依赖运行时 Policy,也不执行分发包中的任意代码。正式激活仍等待官方 Ed25519 公钥(production key activation HOLD);生产私钥只能来自部署 secret,测试使用注入的临时密钥。 +- 官方插件运行架构:Renderer 只调用 Main-owned Marketplace facade;Main 负责账号、请求 deadline、签名/摘要校验、不可变 Release、current selection 与原子回滚。Pi parent worker 使用冻结的 effective snapshot,将每个有效 Skill 与已验证 Package Store root 成对传给 resource loader、Extension Host 和 CLI;普通 `skill_only` 下载包不依赖运行时 Policy,也不得携带可执行脚本。Project Scaffold 的 `.mjs` 是代码所有的 bundled 例外,只能从签名客户端固定资源根通过应用自带 Node 执行,不能扩展为 Marketplace 下载 artifact 的脚本权限。正式激活仍等待官方 Ed25519 公钥(production key activation HOLD);生产私钥只能来自部署 secret,测试使用注入的临时密钥。 - 原生 Web Search 是冻结 selected model/provider/credential 的 parent-only 模型工具;只有 Works `model_capabilities` 明确声明支持时才出现,使用当前模型的正常计费链,不依赖 Marketplace 获取、Release、Admission、Hosted Provider 或 `agent_browser` 回退。 - 本机 Device Packages 只由 Conversation 工具发起安装,支持 npm、Git、绝对本地 Plugin 目录和 loose `SKILL.md`。Main 在预览和独立确认后提交不可变本机 generation,禁用生命周期脚本;确认界面明确提示 Pi extension 以桌面当前用户权限执行。新建/空闲 parent 会同时加载所有显式安装且当前启用的 Skill,以及必需 Makelore 扩展之外的全部 Pi extension,active parent 在 turn settled 后切换,child 不继承这些资源,也不会扫描 ambient 项目或用户目录;Renderer 不提供安装源选择器。 - 提示词博物馆:只陈列经过审核的作品预览、Prompt、分类以及作者/来源/许可证信息,支持搜索、使用场景/风格/主体筛选和详情抽屉;“使用此 Prompt”只把原文带回当前 Canvas 对话草稿,不自动发送、不构成社区。列表和详情数据由服务端提供,客户端不打包数据集;服务端字段契约见 [`docs/prompt-museum-server-contract.md`](docs/prompt-museum-server-contract.md)。 From c5014030768e22ab95464494fca2d0892d901c8a Mon Sep 17 00:00:00 2001 From: inman Date: Sat, 5 Sep 2026 12:45:53 +0800 Subject: [PATCH 08/22] docs: close repository pull integration --- ...260905-finalize-pull-integration-c49a71.md | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .project-docs/30-worklog/tasks/20260905-finalize-pull-integration-c49a71.md diff --git a/.project-docs/30-worklog/tasks/20260905-finalize-pull-integration-c49a71.md b/.project-docs/30-worklog/tasks/20260905-finalize-pull-integration-c49a71.md new file mode 100644 index 0000000..42f4f55 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260905-finalize-pull-integration-c49a71.md @@ -0,0 +1,76 @@ +# Task: Finalize repository pull integration + +## Identity + +- Task ID: 20260905-finalize-pull-integration-c49a71 +- Mode: Integration +- Branch: main +- Worktree: /Users/inmanx/Documents/makelore +- Base commit: 8064add2420e5a2f571c1bb99e64589b66473c75 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Close the project-document ownership gate from synchronized base + `8064add2420e5a2f571c1bb99e64589b66473c75`, which already contains the remote + merge and canonical reconciliation. +- Verify remote ancestry, ahead/behind state, Git whitespace, task-registry + consistency, and the final worktree without changing product behavior. + +## Intent And Constraints + +- Treat prior integration task `20260905-integrate-remote-main-b83d6f` and merge + `a2cb07f` as immutable source evidence. +- Make no additional canonical-document, product-code, package, deployment, + publication, push, rebase, reset, or history-rewrite change. +- Keep only this task's own record as new project documentation. + +## Project Context Loaded + +- Concurrent Task Gate: passed for this exact `main` worktree, Integration mode, + base commit, and exclusive integration lock. +- Planning Gate: passed after re-reading the active and source task records, + current integrated state, decision index, and system overview. +- Other active local tasks: none. +- Semantic-conflict assessment: none remains. ADR-008 and the Project Scaffold + integration coexist with the authoritative three-module product boundary and + retired Learning implementation. + +## Outcome + +- Confirmed that local `main` contains remote `origin/main@d642d76` as an ancestor + and preserves the ordinary two-parent merge `a2cb07f` plus documentation + reconciliation `8064add`. +- Confirmed that the two remote task records remain recoverable in remote parent + history but are absent from the current default tree, satisfying task-owned + document boundaries without changing their contents. +- Added no product or canonical state changes. This record is the only change made + from the fresh closure baseline. + +## Verification + +- `check_project_docs.py` passed before claiming the closure task. +- `git rev-list --left-right --count main...origin/main` returned `9 0` before + committing this final task record; local is not behind the fetched remote. +- `git merge-base --is-ancestor origin/main main` passed. +- `git show` confirmed merge `a2cb07f` has parents `53f3db3` and `d642d76`. +- `git diff --check origin/main..main` passed. +- `task_context.py doctor` reported a consistent task registry. +- `check_doc_drift.py --task-id 20260905-finalize-pull-integration-c49a71` + passed from the fresh post-merge baseline. +- Product verification is inherited unchanged from the immediately preceding + integration task: focused Vitest 228/228, canonical-path scaffold tests 12/12, + typecheck, full unit 1,859 with 3 skips plus pressure 1/1, lint 0 errors, Vite + production build, and targeted Electron E2E 1/1 all passed. + +## Follow-ups + +- The macOS `/var` versus `/private/var` copied-script test-path portability issue + and production Works Square migration `20260904_project_scaffold_0085` remain the + two explicit follow-ups recorded by the source integration task. + +## Promotion Candidates + +- None. Canonical reconciliation was completed by the source integration task; + this task only closes the ownership gate from a clean baseline. From f8eee430f4f4167dc2ecc682ece96c652cdde14b Mon Sep 17 00:00:00 2001 From: inman Date: Sat, 5 Sep 2026 14:56:12 +0800 Subject: [PATCH 09/22] docs: record running development environment --- .../tasks/20260905-start-project-d71a4c.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .project-docs/30-worklog/tasks/20260905-start-project-d71a4c.md diff --git a/.project-docs/30-worklog/tasks/20260905-start-project-d71a4c.md b/.project-docs/30-worklog/tasks/20260905-start-project-d71a4c.md new file mode 100644 index 0000000..f0f62a2 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260905-start-project-d71a4c.md @@ -0,0 +1,60 @@ +# Task: Start Makelore development project + +## Identity + +- Task ID: 20260905-start-project-d71a4c +- Mode: Feature +- Branch: main +- Worktree: /Users/inmanx/Documents/makelore +- Base commit: c5014030768e22ab95464494fca2d0892d901c8a +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Start the Makelore Electron application in desktop development mode from the + current local `main` checkout using the pnpm version pinned by `packageManager`. +- Confirm the Renderer, Electron Main, Preload, utility worker, and Host API reach + a usable running state without terminating unrelated application state. + +## Intent And Constraints + +- Use `corepack pnpm run dev` with pinned pnpm `10.33.4`. +- Do not change application configuration, global user-data paths, single-instance + behavior, or environment contracts merely to bypass an existing installed app. +- Do not force-quit the installed Makelore instance without the user's direction, + because it may contain active or unsaved work. + +## Outcome + +- After user confirmation, gracefully quit the installed + `/Applications/Makelore.app` instance and confirmed its process exited. +- Started `corepack pnpm run dev`; Vite became ready at + `http://localhost:5173/`, all Electron development targets compiled, and the + Electron development application opened without a single-instance-lock exit. +- The Renderer, Electron Main, Preload, utility worker, and authenticated Host API + are running in the retained development session. + +## Verification + +- `corepack pnpm --version` returned `10.33.4`. +- Port inspection before the second launch found no listener on TCP 5173 or 13210. +- Development output reached `VITE v7.3.1 ready`, built Main, Preload, and utility + bundles, started Electron PID 73770, and reported `Host API server listening on + http://127.0.0.1:13210`. +- `http://localhost:5173/` returned HTTP 200. +- An unauthenticated request to `http://127.0.0.1:13210/` returned the expected + HTTP 401, confirming the Host API listener and authentication gate are active. +- System Events reported an Electron window named `Makelore`; listener inspection + showed Vite on localhost TCP 5173 and Electron on TCP 127.0.0.1:13210. +- Startup emitted only non-fatal development warnings about login-item permission, + mixed static/dynamic imports, and stale Browserslist data. + +## Follow-ups + +- Keep the development session running for interactive use. Stop it with Ctrl-C + when development work is finished. + +## Promotion Candidates + +- None. This is an operational startup task and does not change product behavior. From de8ce283dd652fbe0344c2865b5d8d348275da49 Mon Sep 17 00:00:00 2001 From: inman Date: Sun, 6 Sep 2026 15:07:19 +0800 Subject: [PATCH 10/22] feat: redesign coding default entry --- .../20260906-coding-default-page-a7c42e.md | 99 +++++++++++++++++++ README.md | 1 + design-qa.md | 47 +++++++++ src/App.tsx | 2 +- src/components/layout/Sidebar.tsx | 10 +- src/lib/coding-project-entry.ts | 12 +++ src/pages/Chat/CodingChatPanel.tsx | 99 ++++++++++++++++--- src/pages/Chat/CodingConversationTimeline.tsx | 20 ++-- src/pages/Chat/CodingWelcomeHero.tsx | 29 ++++++ src/pages/Chat/index.tsx | 2 + tests/e2e/main-navigation.spec.ts | 12 ++- tests/unit/coding-chat-panel.test.tsx | 29 +++++- 12 files changed, 334 insertions(+), 28 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260906-coding-default-page-a7c42e.md create mode 100644 design-qa.md create mode 100644 src/lib/coding-project-entry.ts create mode 100644 src/pages/Chat/CodingWelcomeHero.tsx diff --git a/.project-docs/30-worklog/tasks/20260906-coding-default-page-a7c42e.md b/.project-docs/30-worklog/tasks/20260906-coding-default-page-a7c42e.md new file mode 100644 index 0000000..f820e15 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260906-coding-default-page-a7c42e.md @@ -0,0 +1,99 @@ +# Task: Redesign Coding default page + +## Identity + +- Task ID: 20260906-coding-default-page-a7c42e +- Mode: Feature +- Branch: codex/20260906-coding-default-page-a7c42e-coding-default-page +- Worktree: /Users/inmanx/Documents/makelore-task-coding-default-page-a7c42e +- Base commit: f8eee430f4f4167dc2ecc682ece96c652cdde14b +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Replace the no-project Code entry redirect with a dedicated conversation-first + landing state on `/chat`. +- Restyle both the no-project landing and the empty Conversation timeline around + the existing Makelore M brand mark, a large centered prompt, and the existing + bottom-composer hierarchy shown in the supplied Codex reference. +- Keep project creation reachable from the landing surface and update focused + Renderer/Electron coverage plus README current-state wording. + +## Intent And Constraints + +- Reuse `src/assets/logo.svg`; do not copy or approximate the Codex mark and do + not generate a new brand asset. +- Preserve the single Makelore light visual system, bundled typography, Chinese + UI, project-local `.makelore` authority, and all Main-owned runtime/API seams. +- The no-project call to action must open the existing Sidebar project-creation + flow rather than introducing a second creation implementation. +- Existing projects, Agent selection, Conversation creation, Composer behavior, + model/runtime controls, and accepted/uncertain mutation semantics remain + unchanged. +- Completed peer tasks for Plugin rehoming and removal of the project setup gate + overlap in Code-entry files but have no committed branch tips to consume. This + task therefore stays isolated on the recorded base and keeps its changes small + enough for later semantic integration. + +## Outcome + +- Code now resolves an empty local project list to `/chat` instead of redirecting + to Project Configuration. +- The no-project view uses the existing Makelore M vector asset, a large centered + guide sentence, and a low, wide project starter modeled on the supplied Codex + composition while retaining the Makelore sidebar and light visual system. +- Every project-starter action reuses the Sidebar's existing project-creation + dialog through a renderer-local event bridge; the secondary action opens the + existing project/settings route. +- Empty Conversations reuse the same branded hero above the real editable + Composer and automatically yield to the normal timeline after the first entry. +- README current-state wording, focused unit coverage, and the Code-entry Electron + navigation regression were updated. A side-by-side visual QA pass is recorded + in project-root `design-qa.md` with `final result: passed`. + +## Verification + +- `corepack pnpm install --frozen-lockfile` — passed with pinned pnpm `10.33.4`. +- `corepack pnpm run typecheck` — passed. +- `corepack pnpm exec vitest run tests/unit/coding-chat-panel.test.tsx tests/unit/coding-conversation-timeline.test.tsx tests/unit/module-navigation.test.tsx tests/unit/app-module-provider-gate.test.tsx --maxWorkers=4` + — 4 files, 58 tests passed. +- `corepack pnpm test` — 222 files passed; 1,861 tests passed and 3 skipped. +- `corepack pnpm run lint:check` — passed with 0 errors and the repository's 5 + pre-existing warnings in `src/pages/Home/index.tsx` and + `src/pages/Makelore/index.tsx`. +- `corepack pnpm run build:vite` — Renderer, Electron Main, Preload, and utility + worker production builds passed; only existing Browserslist, mixed-import, and + chunk-size warnings were emitted. +- `corepack pnpm exec playwright test tests/e2e/main-navigation.spec.ts --grep + "returns to the module chooser"` — passed against the built Electron app, + including `/chat`, branded hero, starter visibility, and project-dialog launch. +- `git diff --check` — passed. +- Visual comparison: + `/Users/inmanx/.codex/visualizations/2026/09/06/01a0756a-b6f5-7892-a11c-1c1608ebe592/codex-reference-vs-makelore.png` + — no actionable P0/P1/P2 differences. + +## Follow-ups + +- During integration, semantically reconcile `App.tsx`, `Sidebar.tsx`, and the + Code-entry tests with the concurrently completed but uncommitted Plugin-rehoming + and project-gate-removal tasks; their branch tips contain no commits to merge or + cherry-pick from this feature worktree. +- Recheck the starter's `项目与插件设置` label after those peer changes establish + their final settings terminology. + +## Promotion Candidates + +- Target: `.project-docs/30-worklog/current-state.md` and + `.project-docs/40-domain/business-rules.md`. + Proposal: record that entering Code with no active local project remains on + `/chat`, presents the branded project starter, and routes all creation actions + through the single existing Sidebar flow; empty Conversations share the same + brand hero until their first entry. + Evidence: focused unit coverage, the passing Electron navigation regression, + production build, and the passed side-by-side visual QA. + Future impact: future routing or setup work must not restore the configuration + redirect or a second project-creation implementation. + Human confirmation: not required for intent because the user explicitly + requested this entry behavior; integration still must reconcile the overlapping + peer tasks semantically. diff --git a/README.md b/README.md index 75e6478..2487f76 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工 - 桌面技术栈:Electron、React 19、Vite、TypeScript、Zustand、Tailwind CSS。 - AI 编程核心对话运行时:Electron Main 按需启动一个长驻 Pi Agent Server,同一进程内为每条 Conversation 建立独立的 Runtime、Session、凭证视图、扩展上下文和 JSONL 通道,同时复用 Pi 模块与进程启动成本;Server 异常退出时所有旧通道统一失效,下一次恢复只重启一个 Server。正式包中的 Agent Server 从明确传入的 `resources/pi-runtime` 根定位依赖清单与包入口,不从脚本所在目录或应用 `node_modules` 猜测解析。Renderer 不直接启动或调用 Pi,也不读取其 wire 类型。项目与 Conversation 元数据先从本地读取,选中对话后才按需准备对应逻辑线程;输入框不等待运行时准备完成。同一助手工具批次中,会取得项目写租约的 `bash`、`edit`、`write` 与动态产品工具按顺序执行,避免批次在命令启动前自锁;纯只读工具仍可并行。子 Agent 仍使用独立的短生命周期进程,以保留并发配额、故障和写入边界;macOS 上两类后台进程都使用 Electron 自带的 Helper,不在 Dock 中创建额外应用图标。 +- AI 编程默认入口:进入 Code 后保持在 `/chat`。尚未选择本地项目时,页面以麦洛 M 标识、居中引导语和贴底的项目启动面板承接第一步,主操作复用侧栏现有的新建/打开项目流程;已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。 - AI 编程历史与配额反馈:Pi 上下文压缩不删除用户可见的 active-branch 历史,压缩摘要保持 Main-private;Renderer 首次渲染最近 120 个节点,向上滚动时按 100 个节点追加更早内容并保持阅读位置。Main 精确识别 `token_point_balance_exhausted` / `词元点数余额不足`,只向 Renderer 投影不可重试的稳定配额错误和安全中文提示,重开会话后仍可见且不暴露上游原始错误或 request id。 - 桌面性能策略:应用窗口使用不透明浅色表面并默认保留硬件合成;仅在显式安全模式或短时间内重复 GPU 进程崩溃时启用软件渲染,并把故障原因保存在用户数据目录。启动关键路径只创建本地应用壳,认证、代理、同步、更新和遥测在首帧后延迟初始化;开发版可通过 `app:performance` 快照观察 GPU、进程、WebContents、事件循环与 Renderer Long Task 聚合指标。 - 后台生命周期:Main 统一维护模块活动状态与任务租约。隐藏窗口、离开模块和关闭开发浏览器会释放非必要连接;生成、Code 执行、下载与发布构建持有租约并在完成后释放。已写入 Pi 的 prompt 或上下文整理即使确认超过 10 秒,也会继续持有运行所有权与后台租约,直到迟到响应、权威事件、明确失败或用户清理使其收敛;此时同一 Conversation 不接受重叠 mutation,其他 Conversation 不受影响。各模块的后台连接、轮询和子进程必须通过同一生命周期入口登记。 diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000..c437c73 --- /dev/null +++ b/design-qa.md @@ -0,0 +1,47 @@ +**Comparison Target** + +- Source visual truth: `/var/folders/m7/x9ry3xgx4x50fxb20_0108tr0000gn/T/codex-clipboard-58e2cc35-7a17-4951-8bbc-53fee12d5246.png` +- Implementation screenshot: `/Users/inmanx/.codex/visualizations/2026/09/06/01a0756a-b6f5-7892-a11c-1c1608ebe592/makelore-coding-default.png` +- Combined comparison: `/Users/inmanx/.codex/visualizations/2026/09/06/01a0756a-b6f5-7892-a11c-1c1608ebe592/codex-reference-vs-makelore.png` +- State: reference start screen and Makelore Code with no active local project. +- Viewport: implementation CSS viewport 1280 x 800 at device scale factor 1. The source is 908 x 835 pixels with unknown capture density. For full-view comparison, the source was aspect-fit onto a 1280 x 800 white canvas and the implementation was kept at its native 1280 x 800 size; both canvases were then placed side by side. + +**Findings** + +- No actionable P0, P1, or P2 differences remain. +- Fonts and typography: both screens use a restrained system sans-serif hierarchy. The Makelore prompt has the same single-line, medium-weight emphasis and remains comfortably readable without wrapping at the verified viewport. +- Spacing and layout rhythm: the M mark and guide sentence form one centered hero group, while the project starter stays anchored near the bottom with proportions close to the reference composer. The persistent Makelore sidebar is an intentional product-shell constraint rather than design drift. +- Colors and visual tokens: the implementation keeps Makelore's existing light background, muted neutral surfaces, dark foreground, and subtle elevation. It does not introduce a conflicting theme or decorative palette. +- Image quality and asset fidelity: the hero uses the repository's existing vector Makelore M asset, with no placeholder, CSS drawing, or reconstructed logo. +- Copy and content: the guide sentence is adapted to Makelore, and the bottom surface explains the required no-project action while preserving the reference's prompt-first reading order. + +**Open Questions** + +- None. The source does not include Makelore's persistent sidebar or its required project-selection state; those differences are intentional and necessary. + +**Full-view Comparison Evidence** + +- The combined comparison shows matching visual priority: small centered mark, large central guide copy, substantial white space, and a low, wide action surface. +- The implementation preserves the reference's quiet density while making the unavailable-chat state actionable through the existing project creation flow. + +**Focused Region Comparison Evidence** + +- A separate crop was not needed: at native 1x capture, the M mark, guide sentence, starter metadata, CTA copy, icons, radii, and shadows are all legible in the full-view evidence. + +**Comparison History** + +- Pass 1: no actionable P0/P1/P2 findings. No visual fixes were required after the combined comparison. + +**Implementation Checklist** + +- [x] Replace the old project-configuration default with the Code conversation entry. +- [x] Use the real Makelore M asset in the centered hero. +- [x] Add a large guide sentence and bottom project starter. +- [x] Connect the starter controls to the existing project dialog and settings route. +- [x] Verify the Electron entry interaction and capture the no-project state. + +**Follow-up Polish** + +- P3: after the concurrent project-dialog simplification lands, recheck the starter's secondary label so it reflects the final project/settings terminology exactly. + +final result: passed diff --git a/src/App.tsx b/src/App.tsx index f1cd4a1..a86559a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -218,7 +218,7 @@ function ProjectChatRoute() { if (!project) { if (!cancelled) { setResolvedProjectKey(null); - setResolvedRoute('config'); + setResolvedRoute('chat'); } return; } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index f5e9b8b..ab02f53 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -31,6 +31,7 @@ import { import { getAiModuleForPath } from '@/lib/ai-modules'; import { invokeIpc } from '@/lib/api-client'; import { getAuthUserDisplayName } from '@/lib/auth-user-display'; +import { subscribeCodingProjectCreationRequest } from '@/lib/coding-project-entry'; import { WORKS_SQUARE_TOKEN_USAGE_STALE_EVENT } from '@/lib/works-square-usage-events'; import { fetchWorksTokenUsage, type WorksTokenUsage } from '@/lib/works-square'; import { isWorksTokenUsageExhausted } from '@/lib/works-square-token-usage'; @@ -393,7 +394,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi }, [authUser, refreshTokenUsage]); - const openCreateProject = () => { + const openCreateProject = useCallback(() => { setNewProjectName(''); setNewProjectSelectedPath(''); setNewProjectDirectoryMode('use-selected-directory'); @@ -402,7 +403,12 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi setNewProjectIdentityProjectId(''); setCreateProjectError(null); setCreateDialogOpen(true); - }; + }, []); + + useEffect(() => { + if (!isProgrammingModule) return undefined; + return subscribeCodingProjectCreationRequest(openCreateProject); + }, [isProgrammingModule, openCreateProject]); const closeCreateProject = () => { if (creatingProject) return; diff --git a/src/lib/coding-project-entry.ts b/src/lib/coding-project-entry.ts new file mode 100644 index 0000000..b8c5993 --- /dev/null +++ b/src/lib/coding-project-entry.ts @@ -0,0 +1,12 @@ +const CODING_PROJECT_CREATE_REQUEST_EVENT = 'makelore:coding-project-create-request'; + +export function requestCodingProjectCreation(): void { + if (typeof window === 'undefined') return; + window.dispatchEvent(new Event(CODING_PROJECT_CREATE_REQUEST_EVENT)); +} + +export function subscribeCodingProjectCreationRequest(listener: () => void): () => void { + if (typeof window === 'undefined') return () => undefined; + window.addEventListener(CODING_PROJECT_CREATE_REQUEST_EVENT, listener); + return () => window.removeEventListener(CODING_PROJECT_CREATE_REQUEST_EVENT, listener); +} diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index 35bdd7b..4f27fe4 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -1,8 +1,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - Bot, + ArrowUp, CircleAlert, + FolderKanban, + Plus, RefreshCw, + Settings2, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { @@ -40,10 +43,12 @@ import { CodingConversationSidebar } from './CodingConversationSidebar'; import { CodingConversationHeader } from './CodingConversationHeader'; import { CodingConversationTimeline } from './CodingConversationTimeline'; import { CodingInteractionPanel } from './CodingInteractionPanel'; +import { CodingWelcomeHero } from './CodingWelcomeHero'; import { createLocalConversationSnapshot } from './coding-chat-snapshot'; export interface CodingChatPanelProps { navigationDraft?: string; + onCreateProject?(): void; onOpenProjectSettings?(): void; } @@ -109,6 +114,7 @@ function acceptedPromptCount( export function CodingChatPanel({ navigationDraft, + onCreateProject, onOpenProjectSettings, }: CodingChatPanelProps) { const activeProject = useCodingWorkspaceStore((state) => state.activeProject); @@ -592,18 +598,87 @@ export function CodingChatPanel({ if (!activeProject && workspaceLoadState !== 'loading') { return ( -
-
-
-
+ ) : null}
); diff --git a/src/pages/Chat/index.tsx b/src/pages/Chat/index.tsx index 2871104..6aa4e10 100644 --- a/src/pages/Chat/index.tsx +++ b/src/pages/Chat/index.tsx @@ -1,6 +1,9 @@ import { CodingChatPanel } from './CodingChatPanel'; import { useNavigate } from 'react-router-dom'; -import { requestCodingProjectCreation } from '@/lib/coding-project-entry'; +import { + requestCodingProjectCreation, + requestCodingProjectOpen, +} from '@/lib/coding-project-entry'; function getNavigationDraft(state: unknown): string | undefined { if (!state || typeof state !== 'object') return undefined; @@ -26,6 +29,7 @@ export function Chat() { navigate('/project-config')} /> diff --git a/tests/e2e/main-navigation.spec.ts b/tests/e2e/main-navigation.spec.ts index 50b78e9..7a0e036 100644 --- a/tests/e2e/main-navigation.spec.ts +++ b/tests/e2e/main-navigation.spec.ts @@ -1,6 +1,84 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; +async function seedInactiveCodingProjects(resources: { rootDir: string; userDataDir: string }) { + const now = '2026-09-06T08:00:00.000Z'; + const older = '2026-09-04T08:00:00.000Z'; + const projects = [ + { + id: 'project-moonlight', + name: '月光小游戏', + path: join(resources.rootDir, '月光小游戏'), + createdAt: older, + updatedAt: now, + lastOpenedAt: now, + projectId: '11111111-1111-4111-8111-111111111111', + }, + { + id: 'project-storybook', + name: '故事绘本', + path: join(resources.rootDir, '故事绘本'), + createdAt: older, + updatedAt: older, + lastOpenedAt: older, + projectId: '22222222-2222-4222-8222-222222222222', + }, + ]; + + for (const project of projects) { + await mkdir(join(project.path, '.makelore'), { recursive: true }); + await mkdir(join(project.path, 'knowledge'), { recursive: true }); + await writeFile(join(project.path, '.makelore', 'project.json'), JSON.stringify({ + schemaVersion: 2, + projectType: 'custom', + projectId: project.projectId, + initialized: true, + agents: [], + knowledgeDirectory: 'knowledge', + createdAt: project.createdAt, + updatedAt: project.updatedAt, + })); + } + + await writeFile(join(resources.userDataDir, 'makelore-projects.json'), JSON.stringify({ + projects: { + projects: Object.fromEntries(projects.map(({ projectId: _projectId, ...project }) => [project.id, project])), + activeProjectId: null, + }, + })); +} + test.describe('Makelore module navigation without setup flow', () => { + test('offers a create CTA and horizontal cards for remembered inactive Code projects', async ({ + electronTestResources, + launchElectronApp, + }) => { + await seedInactiveCodingProjects(electronTestResources); + const app = await launchElectronApp({ skipSetup: true }); + + try { + const page = await getStableWindow(app); + await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); + await page.getByTestId('ai-module-option-programming').click(); + + await expect(page).toHaveURL(/\/chat$/); + await expect(page.getByRole('button', { name: '新增项目' })).toBeVisible(); + const projectCards = page.getByTestId('coding-existing-projects'); + await expect(projectCards).toBeVisible(); + await expect(projectCards.getByRole('button')).toHaveCount(2); + await expect(projectCards.getByRole('button').nth(0)).toContainText('月光小游戏'); + await expect(projectCards.getByRole('button').nth(1)).toContainText('故事绘本'); + + await projectCards.getByRole('button', { name: '进入项目 故事绘本' }).click(); + await expect(page.getByTestId('coding-chat-panel')).toBeVisible(); + await expect(page.getByTestId('coding-existing-projects')).toHaveCount(0); + await expect(page.getByTestId('sidebar-course-project-project-storybook')).toHaveAttribute('aria-current', 'page'); + } finally { + await closeElectronApp(app); + } + }); + test('keeps the three module entries in a proportional vertical stack while resizing the window', async ({ launchElectronApp }) => { const app = await launchElectronApp({ skipSetup: true }); @@ -143,7 +221,9 @@ test.describe('Makelore module navigation without setup flow', () => { await expect(page.getByText('请先新建项目')).toHaveCount(0); await expect(page.getByRole('heading', { name: '你想让麦洛和你一起构建什么?' })).toBeVisible(); await expect(page.getByRole('img', { name: '麦洛 M 标识' })).toBeVisible(); - await expect(page.getByTestId('coding-project-starter')).toBeVisible(); + await expect(page.getByRole('button', { name: '新增项目' })).toBeVisible(); + await expect(page.getByTestId('coding-project-starter')).toHaveCount(0); + await expect(page.getByTestId('coding-existing-projects')).toHaveCount(0); await expect(page.getByText('一念成光,万物可创。')).toHaveCount(0); await expect(page.getByRole('img', { name: 'Makelore logo' })).toBeVisible(); await expect(page.getByTestId('sidebar-module-switcher-trigger')).toContainText('编程 Code'); diff --git a/tests/unit/coding-chat-panel.test.tsx b/tests/unit/coding-chat-panel.test.tsx index 2a07666..d9e24fc 100644 --- a/tests/unit/coding-chat-panel.test.tsx +++ b/tests/unit/coding-chat-panel.test.tsx @@ -60,6 +60,12 @@ const project: CodingProjectSummary = { updatedAt: '2026-08-23T00:00:00.000Z', lastOpenedAt: '2026-08-23T00:00:00.000Z', }; +const secondProject: CodingProjectSummary = { + ...project, + id: 'project-2', + name: 'Second local project', + lastOpenedAt: '2026-08-22T00:00:00.000Z', +}; const agent: CodingProjectAgent = { id: 'agent-1', avatarId: 'default', @@ -199,15 +205,17 @@ describe('CodingChatPanel first Conversation', () => { vi.resetModules(); }); - it('shows a branded project starter before any local project is selected', async () => { - projectApi.list.mockResolvedValue({ projects: [], activeProjectId: null }); + it('shows a branded create CTA and horizontal existing-project cards before a project is selected', async () => { + projectApi.list.mockResolvedValue({ projects: [project, secondProject], activeProjectId: null }); const onCreateProject = vi.fn(); + const onOpenProject = vi.fn(); const onOpenProjectSettings = vi.fn(); const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); render( , ); @@ -215,13 +223,18 @@ describe('CodingChatPanel first Conversation', () => { expect(await screen.findByTestId('coding-chat-empty-project')).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '你想让麦洛和你一起构建什么?' })).toBeInTheDocument(); expect(screen.getByRole('img', { name: '麦洛 M 标识' })).toBeInTheDocument(); - expect(screen.getByTestId('coding-project-starter')).toHaveTextContent('尚未选择项目'); + expect(screen.getByRole('button', { name: '新增项目' })).toBeInTheDocument(); + expect(screen.getByTestId('coding-existing-projects')).toHaveTextContent('Local project'); + expect(screen.getByTestId('coding-existing-projects')).toHaveTextContent('Second local project'); + expect(screen.queryByTestId('coding-project-starter')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '项目与插件设置' })).not.toBeInTheDocument(); fireEvent.click(screen.getByTestId('coding-start-project-button')); expect(onCreateProject).toHaveBeenCalledOnce(); - fireEvent.click(screen.getByRole('button', { name: '项目与插件设置' })); - expect(onOpenProjectSettings).toHaveBeenCalledOnce(); + fireEvent.click(screen.getByRole('button', { name: '进入项目 Second local project' })); + expect(onOpenProject).toHaveBeenCalledWith(secondProject.id); + expect(onOpenProjectSettings).not.toHaveBeenCalled(); }); it('makes the first-Conversation textarea editable while runtime metadata is held', async () => { diff --git a/tests/unit/coding-project-entry.test.ts b/tests/unit/coding-project-entry.test.ts new file mode 100644 index 0000000..f2db7c5 --- /dev/null +++ b/tests/unit/coding-project-entry.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + requestCodingProjectCreation, + requestCodingProjectOpen, + subscribeCodingProjectCreationRequest, + subscribeCodingProjectOpenRequest, +} from '@/lib/coding-project-entry'; + +describe('coding project entry events', () => { + it('delivers project creation requests and removes the listener on unsubscribe', () => { + const listener = vi.fn(); + const unsubscribe = subscribeCodingProjectCreationRequest(listener); + + requestCodingProjectCreation(); + expect(listener).toHaveBeenCalledOnce(); + + unsubscribe(); + requestCodingProjectCreation(); + expect(listener).toHaveBeenCalledOnce(); + }); + + it('delivers the selected project id and ignores requests after unsubscribe', () => { + const listener = vi.fn(); + const unsubscribe = subscribeCodingProjectOpenRequest(listener); + + requestCodingProjectOpen('project-2'); + expect(listener).toHaveBeenCalledWith('project-2'); + + unsubscribe(); + requestCodingProjectOpen('project-3'); + expect(listener).toHaveBeenCalledOnce(); + }); +}); From ce90f57ce7b9a16aee19ea455071f14aae1a6c26 Mon Sep 17 00:00:00 2001 From: inman Date: Sun, 6 Sep 2026 16:12:24 +0800 Subject: [PATCH 12/22] fix: simplify coding project card rail --- .../20260906-coding-default-page-a7c42e.md | 28 +++++++++++++------ design-qa.md | 14 ++++++---- src/pages/Chat/CodingChatPanel.tsx | 9 +----- tests/e2e/main-navigation.spec.ts | 2 ++ tests/unit/coding-chat-panel.test.tsx | 2 ++ 5 files changed, 32 insertions(+), 23 deletions(-) diff --git a/.project-docs/30-worklog/tasks/20260906-coding-default-page-a7c42e.md b/.project-docs/30-worklog/tasks/20260906-coding-default-page-a7c42e.md index de163d9..836d25c 100644 --- a/.project-docs/30-worklog/tasks/20260906-coding-default-page-a7c42e.md +++ b/.project-docs/30-worklog/tasks/20260906-coding-default-page-a7c42e.md @@ -22,6 +22,8 @@ - Apply the user's follow-up correction: the lower landing surface is a standalone `新增项目` CTA followed by horizontally arranged existing-project cards, not a composer-shaped project starter. +- Apply the user's final copy correction: remove the visible `已有项目` heading and + `选择一个项目继续` helper while retaining the horizontal project-card rail. ## Intent And Constraints @@ -47,7 +49,8 @@ - The no-project view uses the existing Makelore M vector asset and a large centered guide sentence while retaining the Makelore sidebar and light visual system. Its lower surface now uses the user's clarified standalone orange - `新增项目` CTA followed by an overflow-safe horizontal row of existing projects. + `新增项目` CTA followed directly by an overflow-safe horizontal row of existing + projects, without the redundant visible project-section heading or helper copy. - Project creation reuses the Sidebar's existing dialog through a renderer-local event bridge; project-card selection uses the same bridge to invoke the Sidebar's validated entry flow instead of duplicating project activation. @@ -69,6 +72,12 @@ - Follow-up focused run: `corepack pnpm exec vitest run tests/unit/coding-chat-panel.test.tsx tests/unit/coding-project-entry.test.ts tests/unit/module-navigation.test.tsx tests/unit/app-module-provider-gate.test.tsx --maxWorkers=4` — 4 files, 48 tests passed. +- Final-copy focused run: + `corepack pnpm exec vitest run tests/unit/coding-chat-panel.test.tsx tests/unit/coding-project-entry.test.ts --maxWorkers=2` + — 2 files, 23 tests passed, including assertions that `已有项目` and + `选择一个项目继续` stay absent while real cards remain. +- `corepack pnpm exec eslint src/pages/Chat/CodingChatPanel.tsx tests/unit/coding-chat-panel.test.tsx tests/e2e/main-navigation.spec.ts` + — passed with no output. - `corepack pnpm test` — 223 files passed; 1,863 tests passed and 3 skipped. - `corepack pnpm run lint:check` — passed with 0 errors and the repository's 5 pre-existing warnings in `src/pages/Home/index.tsx` and @@ -79,14 +88,15 @@ - `corepack pnpm exec playwright test tests/e2e/main-navigation.spec.ts --grep "offers a create CTA|returns to the module chooser"` — 2 tests passed against the built Electron app, covering empty and remembered-project entry states, - horizontal card order, direct card entry, `/chat`, and project-dialog launch. + absent section/helper copy, horizontal card order, direct card entry, `/chat`, + and project-dialog launch. - `git diff --check` — passed. - Visual comparison: - `/Users/inmanx/.codex/visualizations/2026/09/06/01a0756a-b6f5-7892-a11c-1c1608ebe592/codex-reference-vs-makelore-v2.png` + `/Users/inmanx/.codex/visualizations/2026/09/06/01a0756a-b6f5-7892-a11c-1c1608ebe592/codex-reference-vs-makelore-v3.png` plus the focused lower crop - `/Users/inmanx/.codex/visualizations/2026/09/06/01a0756a-b6f5-7892-a11c-1c1608ebe592/codex-reference-vs-makelore-v2-bottom.png` - — the user-identified composer/CTA mismatch is resolved and no actionable - P0/P1/P2 differences remain. + `/Users/inmanx/.codex/visualizations/2026/09/06/01a0756a-b6f5-7892-a11c-1c1608ebe592/codex-reference-vs-makelore-v3-bottom.png` + — the user-identified composer/CTA and redundant-copy mismatches are resolved, + and no actionable P0/P1/P2 differences remain. ## Follow-ups @@ -101,9 +111,9 @@ `.project-docs/40-domain/business-rules.md`. Proposal: record that entering Code with no active local project remains on `/chat`, presents the branded hero, a standalone `新增项目` CTA, and real local - projects as a horizontal card row. Creation and project-card entry route through - the single existing Sidebar flows; empty Conversations share the same brand - hero until their first entry. + projects as an unlabeled horizontal card row. Creation and project-card entry + route through the single existing Sidebar flows; empty Conversations share the + same brand hero until their first entry. Evidence: focused unit coverage, the passing Electron navigation regression, production build, and the passed side-by-side visual QA. Future impact: future routing or setup work must not restore the configuration diff --git a/design-qa.md b/design-qa.md index e50fe73..2a3d870 100644 --- a/design-qa.md +++ b/design-qa.md @@ -1,20 +1,20 @@ **Comparison Target** -- Source visual truth: `/var/folders/m7/x9ry3xgx4x50fxb20_0108tr0000gn/T/codex-clipboard-58e2cc35-7a17-4951-8bbc-53fee12d5246.png`, superseded at the lower action area by the user's explicit follow-up: use a standalone `新增项目` CTA and horizontally arranged existing-project cards, not a conversation box. -- Implementation screenshot: `/Users/inmanx/.codex/visualizations/2026/09/06/01a0756a-b6f5-7892-a11c-1c1608ebe592/makelore-coding-default-v2.png` -- Full-view comparison: `/Users/inmanx/.codex/visualizations/2026/09/06/01a0756a-b6f5-7892-a11c-1c1608ebe592/codex-reference-vs-makelore-v2.png` -- Focused lower-region comparison: `/Users/inmanx/.codex/visualizations/2026/09/06/01a0756a-b6f5-7892-a11c-1c1608ebe592/codex-reference-vs-makelore-v2-bottom.png` +- Source visual truth: `/var/folders/m7/x9ry3xgx4x50fxb20_0108tr0000gn/T/codex-clipboard-58e2cc35-7a17-4951-8bbc-53fee12d5246.png`, superseded at the lower action area by the user's explicit follow-ups: use a standalone `新增项目` CTA and horizontally arranged existing-project cards, not a conversation box, and omit the visible `已有项目` / `选择一个项目继续` labels. +- Implementation screenshot: `/Users/inmanx/.codex/visualizations/2026/09/06/01a0756a-b6f5-7892-a11c-1c1608ebe592/makelore-coding-default-v3.png` +- Full-view comparison: `/Users/inmanx/.codex/visualizations/2026/09/06/01a0756a-b6f5-7892-a11c-1c1608ebe592/codex-reference-vs-makelore-v3.png` +- Focused lower-region comparison: `/Users/inmanx/.codex/visualizations/2026/09/06/01a0756a-b6f5-7892-a11c-1c1608ebe592/codex-reference-vs-makelore-v3-bottom.png` - State: Codex reference start screen and Makelore Code with two remembered local projects but no active project. - Viewport: implementation CSS viewport 1280 x 800 at device scale factor 1. The source is 908 x 835 pixels with unknown capture density. For full-view comparison, the source was aspect-fit onto a 1280 x 800 white canvas and the implementation remained at its native 1280 x 800 size. The focused comparison uses a 400-pixel-tall lower crop from each normalized content region. **Findings** - No actionable P0, P1, or P2 differences remain after applying the user's correction. -- Fonts and typography: the M hero keeps the reference's restrained system-sans hierarchy and single-line display copy. The CTA, section label, project names, and recency metadata form a clear descending hierarchy without crowding or truncation at the verified viewport. +- Fonts and typography: the M hero keeps the reference's restrained system-sans hierarchy and single-line display copy. The CTA, project names, and recency metadata form a clear descending hierarchy without crowding or truncation at the verified viewport. - Spacing and layout rhythm: the hero retains generous white space. The standalone CTA is visually separated from the existing-project row, and fixed-width cards create a true horizontal browsing pattern without increasing page height as the catalog grows. - Colors and visual tokens: the CTA uses the existing Makelore brand orange and the cards use existing neutral surface tokens, preserving the single light product system and one-accent discipline. - Image quality and asset fidelity: the hero uses the repository's existing vector Makelore M asset. Project cards use the product's installed icon library; no placeholder image, CSS drawing, or reconstructed logo is present. -- Copy and content: `新增项目`, `已有项目`, and `选择一个项目继续` state the two available paths directly. Project cards use real project names and last-opened timestamps from the local catalog. +- Copy and content: `新增项目` remains the only action label above the card rail. The redundant `已有项目` heading and `选择一个项目继续` helper are absent; project cards themselves use real project names and last-opened timestamps from the local catalog. - Interaction and accessibility: the CTA opens the existing creation dialog; each card has a project-specific accessible name, keyboard focus treatment, pressed feedback, horizontal overflow, and routes through the Sidebar's existing validated project-entry behavior. **Open Questions** @@ -35,6 +35,7 @@ - Pass 1: the user identified a P1 interaction-model mismatch: the lower surface looked like a default conversation box and omitted horizontally arranged existing projects. - Fix: replaced the composer-shaped starter with a standalone `新增项目` CTA; added a horizontal, overflow-safe row backed by the real project catalog; connected card clicks to the existing Sidebar entry path; added unit and Electron coverage. - Pass 2: the revised full-view and focused comparisons show the corrected CTA/card hierarchy with no remaining actionable P0/P1/P2 findings. +- Pass 3: removed the visible project-section heading and helper copy at the user's request. The fresh screenshot and focused comparison confirm that the CTA now leads directly into the unchanged horizontal card rail. **Implementation Checklist** @@ -42,6 +43,7 @@ - [x] Remove the composer-shaped no-project starter. - [x] Add one standalone `新增项目` primary CTA. - [x] Render real existing projects as horizontal cards. +- [x] Remove the visible `已有项目` and `选择一个项目继续` labels. - [x] Reuse the existing creation and validated project-entry flows. - [x] Verify both the empty catalog and remembered-project states in Electron. diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index 32d1440..b8c2590 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -647,14 +647,7 @@ export function CodingChatPanel({ {projects.length > 0 ? ( -
-
-

- 已有项目 -

-

选择一个项目继续

-
- +
{ await expect(page).toHaveURL(/\/chat$/); await expect(page.getByRole('button', { name: '新增项目' })).toBeVisible(); + await expect(page.getByText('已有项目')).toHaveCount(0); + await expect(page.getByText('选择一个项目继续')).toHaveCount(0); const projectCards = page.getByTestId('coding-existing-projects'); await expect(projectCards).toBeVisible(); await expect(projectCards.getByRole('button')).toHaveCount(2); diff --git a/tests/unit/coding-chat-panel.test.tsx b/tests/unit/coding-chat-panel.test.tsx index d9e24fc..8231883 100644 --- a/tests/unit/coding-chat-panel.test.tsx +++ b/tests/unit/coding-chat-panel.test.tsx @@ -226,6 +226,8 @@ describe('CodingChatPanel first Conversation', () => { expect(screen.getByRole('button', { name: '新增项目' })).toBeInTheDocument(); expect(screen.getByTestId('coding-existing-projects')).toHaveTextContent('Local project'); expect(screen.getByTestId('coding-existing-projects')).toHaveTextContent('Second local project'); + expect(screen.queryByText('已有项目')).not.toBeInTheDocument(); + expect(screen.queryByText('选择一个项目继续')).not.toBeInTheDocument(); expect(screen.queryByTestId('coding-project-starter')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '项目与插件设置' })).not.toBeInTheDocument(); From 1dfa9221c442bf029e9ea496facf8582300c375b Mon Sep 17 00:00:00 2001 From: inman Date: Mon, 7 Sep 2026 10:06:23 +0800 Subject: [PATCH 13/22] docs: record development startup task --- .../tasks/20260905-start-project-a4c7e9.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .project-docs/30-worklog/tasks/20260905-start-project-a4c7e9.md diff --git a/.project-docs/30-worklog/tasks/20260905-start-project-a4c7e9.md b/.project-docs/30-worklog/tasks/20260905-start-project-a4c7e9.md new file mode 100644 index 0000000..b295c9d --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260905-start-project-a4c7e9.md @@ -0,0 +1,57 @@ +# Task: Start Makelore development project + +## Identity + +- Task ID: 20260905-start-project-a4c7e9 +- Mode: Feature +- Branch: main +- Worktree: /Users/inmanx/Documents/makelore +- Base commit: f8eee430f4f4167dc2ecc682ece96c652cdde14b +- Owner: codex +- Status: Planning + +## Scope + +- Start the Makelore Electron application in desktop development mode from the + current local `main` checkout with the pnpm version pinned by `packageManager`. +- Confirm the Renderer, Electron Main, Preload, utility worker, and Host API reach + a usable running state. + +## Intent And Constraints + +- Use `corepack pnpm run dev` with pinned pnpm `10.33.4`. +- Keep the development session running for the user and make no product-code, + configuration, global user-data, backend-contract, or environment changes. +- Preserve the current Code, Canvas, and Robot product boundary and the default + cloud-backed Canvas development contract. + +## Outcome + +- Started `corepack pnpm run dev`; Vite became ready at + `http://localhost:5173/`, all Electron development targets compiled, and the + Electron development window opened as `Makelore`. +- Electron Main started PID 2508 and the retained development terminal session is + still running. + +## Verification + +- `corepack pnpm --version` returned `10.33.4`. +- Port inspection before launch found no listener on TCP 5173 or 13210. +- Development output reached `VITE v7.3.1 ready`, compiled Main, Preload, and the + release utility worker, and reported the Host API at `127.0.0.1:13210`. +- `http://localhost:5173/` returned HTTP 200. +- An unauthenticated request to `http://127.0.0.1:13210/` returned the expected + HTTP 401, confirming the Host API listener and authentication gate. +- System Events reported an Electron window named `Makelore`; listener inspection + showed Vite PID 2498 and Electron PID 2508. +- Startup emitted only non-fatal development warnings about login-item permission, + mixed static/dynamic imports, and stale Browserslist data. + +## Follow-ups + +- Keep the retained development session running for interactive use. Stop it with + Ctrl-C when development work is finished. + +## Promotion Candidates + +- None. This is an operational startup task and does not change product behavior. From af13aca0036bc6584f41713b7a071809ec45d23c Mon Sep 17 00:00:00 2001 From: inman Date: Mon, 7 Sep 2026 10:06:37 +0800 Subject: [PATCH 14/22] feat: simplify code project creation --- .../20260906-remove-project-gate-7c31.md | 123 ++++++++++++ README.md | 2 +- electron/coding-projects/project-service.ts | 114 ++++++----- src/App.tsx | 2 +- src/components/layout/MainLayout.tsx | 29 +-- src/components/layout/Sidebar.tsx | 146 +-------------- src/pages/Chat/CodingChatPanel.tsx | 19 ++ src/pages/ProjectConfiguration/index.tsx | 177 +----------------- tests/e2e/coding-project-identity.spec.ts | 94 +++------- .../e2e/project-configuration-skills.spec.ts | 18 +- tests/unit/app-module-provider-gate.test.tsx | 29 +++ tests/unit/coding-chat-panel.test.tsx | 18 ++ tests/unit/coding-core-routes.test.ts | 2 +- tests/unit/coding-project-identity.test.ts | 39 +++- tests/unit/main-layout-module-gate.test.tsx | 25 +-- 15 files changed, 351 insertions(+), 486 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260906-remove-project-gate-7c31.md diff --git a/.project-docs/30-worklog/tasks/20260906-remove-project-gate-7c31.md b/.project-docs/30-worklog/tasks/20260906-remove-project-gate-7c31.md new file mode 100644 index 0000000..0ed00a9 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260906-remove-project-gate-7c31.md @@ -0,0 +1,123 @@ +# Task: Remove project creation gate and template selection + +## Identity + +- Task ID: 20260906-remove-project-gate-7c31 +- Mode: Feature +- Branch: codex/20260906-remove-project-gate-7c31-remove-project-gate-7c31 +- Worktree: /Users/inmanx/Documents/makelore-task-remove-project-gate-7c31 +- Base commit: f8eee430f4f4167dc2ecc682ece96c652cdde14b +- Owner: codex +- Status: Ready for integration + +## Scope + +- Remove the user-facing project creation identity/type choices from the Code + sidebar and use the existing safe defaults instead. +- Stop treating `config.initialized` as a navigation/UI gate for an otherwise + valid local Code project. +- Keep an empty project usable by presenting a non-blocking route to project + settings when no Agent exists. +- Keep the durable project ID as an internal service concern: remove it from the + normal Project Configuration UI and automatically assign one when a valid + legacy project lacks it. +- Update focused Renderer tests, the shared Electron project-creation flow, and + README product-state documentation. + +## Intent And Constraints + +- A new project receives a Main-generated local UUID and the existing internal + `interactive_ai_app` type without asking the user to choose either during + creation. +- Preserve `.makelore/project.json`, historical project-type normalization, + underlying identity compatibility endpoints, publish contracts, and existing + custom projects without exposing identity choices to young users. +- Keep the bundled Project Scaffold Skill available as an optional Plugin; it + must not be part of, or a prerequisite for, project creation. +- Missing or invalid project metadata remains an entry error. A valid legacy + config missing only `projectId` is repaired by Main with a generated UUID; + the legacy `initialized` Boolean no longer blocks a valid project. +- Renderer backend access continues through the existing Host API/store seams. + +## Outcome + +- Simplified the Code project creation dialog to directory selection only. + Main still generates the local project UUID, while the Renderer writes the + existing internal `interactive_ai_app` default without presenting identity, + type, or template choices. +- Removed the `initialized` navigation redirect and full-workspace overlay for + valid project metadata. Missing or invalid metadata still routes to Project + Configuration. +- Added an empty-Agent state in the coding chat so a newly created project opens + immediately and offers a direct, non-blocking route to create its first Agent. +- Reworded Project Configuration save affordances so the retained compatibility + write to `initialized` is no longer described as an initialization ceremony. +- Removed the Project Configuration identity card, raw UUID, bind form, and + independent-copy action. Main now serializes automatic UUID assignment for a + valid legacy project missing `projectId`, including concurrent reads, while + retaining the underlying compatibility endpoints and immutable schema rules. +- Replaced the last creation-dialog identity terminology with youth-readable + copy: only the folder choice is explained; all other setup is automatic. +- Preserved project schemas, historical project types, publishing contracts, + existing custom projects, and the optional bundled Project Scaffold + Plugin/Skill. +- Updated README product-state documentation and focused unit/Electron coverage. +- Diagnosed a post-verification UI mismatch as a development-runtime issue: the + visible Electron window had been launched from the separate Plugins/settings + task worktree, whose source still contained the former identity choices. The + stale process group was stopped and the verified feature worktree was started + on the standard development ports. + +## Verification + +- `corepack pnpm install --frozen-lockfile` — passed with the repository-pinned + pnpm 10.33.4. +- `corepack pnpm run typecheck` — passed. +- Focused ESLint across all changed source/test files — passed. +- Focused Renderer/Main tests — 60 passed across App routing, Main layout, + Sidebar, CodingChatPanel, Project Configuration, config stores, and serialized + automatic legacy identity assignment. +- Adjacent project-schema/store tests — 34 passed. +- `corepack pnpm run lint:check` — passed with zero errors; five pre-existing + warnings remain in unrelated Home/Makelore files. +- `corepack pnpm test` — passed: 1,862 ordinary tests passed, three skipped, and + the isolated pressure test passed 1/1. +- `corepack pnpm run build:vite` — passed for Renderer, Main, Preload, and the + release utility worker. +- `corepack pnpm exec playwright test tests/e2e/coding-project-identity.spec.ts tests/e2e/project-configuration-skills.spec.ts` + — passed (2/2), covering directory-only creation, direct chat entry, absence + of all identity terminology and controls, automatic legacy UUID repair, and + the empty-Agent settings route. +- Live runtime verification after restart — Vite PID 16968 and Electron PID + 16975 both resolve to this task worktree; the active Renderer command reports + the same `app-path`, and the source served on port 5173 contains the automatic + setup copy while none of `项目身份`, `创建新的项目 ID`, or `绑定已有项目 ID` + is present. +- `git diff --check` — passed. + +## Follow-ups + +- Promote the project-creation workflow change into ADR-008 and the canonical + project architecture/domain documentation during a serialized Integration + task. +- Physically removing the optional bundled Project Scaffold Plugin/Skill, if + desired, should be a separate explicit decision because it affects packaged + resources and release coverage; this task only removes it from the creation + path. + +## Promotion Candidates + +- Target: ADR-008 and canonical project-creation architecture/domain documents. + Proposal: amend the user-facing creation workflow so identity and project type + are safe defaults instead of required choices, and treat `initialized` as a + compatibility field rather than a workspace navigation gate. Durable project + identity remains Main-owned and is generated automatically for new projects + and valid legacy configs that lack it; raw UUIDs, bind choices, and copy + semantics must not return to ordinary youth-facing UI. Existing custom + projects, type normalization, publishing authority, compatibility endpoints, + and the optional Scaffold Skill remain. Future impact: product documentation + and future creation/configuration UI must not restore these choices as + mandatory or visible setup. Human confirmation is represented by the user's + explicit statements that the product targets young users and service-required + identity should default automatically; canonical promotion still belongs to a + serialized Integration task. diff --git a/README.md b/README.md index 75e6478..770f081 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工 应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与三个模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 -作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“交互式 AI 应用”或“自定义项目”,创建服务只生成 `.makelore/project.json` 与 `knowledge/`;历史 `mini_game` / `mini_program` 配置在读取时归一为交互式 AI 应用,但不会因读取被改写。用户获取、启用并为智能体分配官方 bundled `makelore.project-scaffold` 插件后,可明确调用 `makelore-project-scaffold` Skill,以不覆盖既有路径的方式生成固定六文件 Vite 起步工程;该 Skill 不安装依赖、不联网、不构建、不上传或提审。交互式 AI 应用的项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。 +作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建 Code 项目只要求选择目录:Main 自动生成内部项目 ID,并以内部 `interactive_ai_app` 类型创建 `.makelore/project.json` 与 `knowledge/`,不再让用户选择或查看项目身份、项目类型和模板;缺少项目 ID 的旧项目在读取时由 Main 自动补全。创建成功后直接进入对话工作区,未创建智能体时只显示可选的设置入口,不再用初始化门禁遮挡工作区。已有 `custom` 项目继续受支持;历史 `mini_game` / `mini_program` 配置在读取时归一为交互式 AI 应用,但不会因读取被改写。用户获取、启用并为智能体分配官方 bundled `makelore.project-scaffold` 插件后,仍可按需明确调用 `makelore-project-scaffold` Skill,以不覆盖既有路径的方式生成固定六文件 Vite 起步工程;该 Skill 不是创建前置条件,也不安装依赖、不联网、不构建、不上传或提审。交互式 AI 应用的项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。项目成果预览 `/deliverables` 继续保留。 ## 当前产品状态 diff --git a/electron/coding-projects/project-service.ts b/electron/coding-projects/project-service.ts index 45f4ee0..d74c30f 100644 --- a/electron/coding-projects/project-service.ts +++ b/electron/coding-projects/project-service.ts @@ -211,21 +211,7 @@ export class CodingProjectService { ); } } - const config = await this.readCurrentConfig(projectPath); - if (!config) { - throw new CodingProjectServiceError( - 409, - 'CODING_PROJECT_CONFIG_INVALID', - 'Coding project configuration is unavailable', - ); - } - if (!config.projectId) { - throw new CodingProjectServiceError( - 409, - 'CODING_PROJECT_IDENTITY_REQUIRED', - 'A durable coding project identity is required', - ); - } + const config = (await this.getConfig(project.id)).config; return { project, path: projectPath, projectId: config.projectId }; } @@ -335,19 +321,10 @@ export class CodingProjectService { async getConfig(projectId?: string): Promise { const project = projectId ? await this.getProject(projectId) : await this.requireActiveProject(); - const config = await this.readCurrentConfig(project.path); - if (!config) { - throw new CodingProjectServiceError( - 409, - 'CODING_PROJECT_CONFIG_INVALID', - 'Coding project configuration is unavailable', - ); - } - return { - project, - config, - knowledgeFiles: await this.listKnowledgeFiles(project.path), - }; + const config = await this.requireCurrentConfig(project); + return config.projectId + ? await this.projectConfigSnapshot(project, config) + : await this.ensureProjectIdentity(project); } async saveConfig(projectId: string, value: unknown): Promise { @@ -379,8 +356,8 @@ export class CodingProjectService { ): Promise { const project = await this.getProject(localProjectId); return await this.serializeIdentityTransition(project.path, async () => { - const current = await this.getConfig(project.id); - if (current.config.projectId !== undefined) { + const current = await this.requireCurrentConfig(project); + if (current.projectId !== undefined) { throw new CodingProjectServiceError( 409, 'CODING_PROJECT_IDENTITY_IMMUTABLE', @@ -397,7 +374,7 @@ export class CodingProjectService { nextProjectId, ); const next = { - ...current.config, + ...current, projectId: nextProjectId, updatedAt: this.options.now?.() ?? new Date().toISOString(), }; @@ -407,11 +384,7 @@ export class CodingProjectService { storageFailure(error); } await this.options.onResourcesChanged?.(project); - return { - project: current.project, - config: next, - knowledgeFiles: await this.listKnowledgeFiles(current.project.path), - }; + return await this.projectConfigSnapshot(project, next); }); } @@ -428,8 +401,8 @@ export class CodingProjectService { } const project = await this.getProject(localProjectId); return await this.serializeIdentityTransition(project.path, async () => { - const current = await this.getConfig(project.id); - if (current.config.projectId === undefined) { + const current = await this.requireCurrentConfig(project); + if (current.projectId === undefined) { throw new CodingProjectServiceError( 409, 'CODING_PROJECT_IDENTITY_REQUIRED', @@ -442,11 +415,11 @@ export class CodingProjectService { ); await this.options.onProjectIdentityChanging?.( project, - current.config.projectId, + current.projectId, nextProjectId, ); const next = { - ...current.config, + ...current, projectId: nextProjectId, updatedAt: this.options.now?.() ?? new Date().toISOString(), }; @@ -456,11 +429,7 @@ export class CodingProjectService { storageFailure(error); } await this.options.onResourcesChanged?.(project); - return { - project: current.project, - config: next, - knowledgeFiles: await this.listKnowledgeFiles(current.project.path), - }; + return await this.projectConfigSnapshot(project, next); }); } @@ -540,6 +509,61 @@ export class CodingProjectService { }); } + private async ensureProjectIdentity( + project: CodingProject, + ): Promise { + return await this.serializeIdentityTransition(project.path, async () => { + const current = await this.requireCurrentConfig(project); + if (current.projectId) { + return await this.projectConfigSnapshot(project, current); + } + const nextProjectId = resolveProjectIdentity( + { kind: 'create' }, + this.options.createProjectId ?? randomUUID, + ); + await this.options.onProjectIdentityChanging?.( + project, + undefined, + nextProjectId, + ); + const next = { + ...current, + projectId: nextProjectId, + updatedAt: this.options.now?.() ?? new Date().toISOString(), + }; + try { + await (this.options.writeConfig ?? writeCodingProjectConfigV2)(project.path, next); + } catch (error) { + storageFailure(error); + } + await this.options.onResourcesChanged?.(project); + return await this.projectConfigSnapshot(project, next); + }); + } + + private async requireCurrentConfig(project: CodingProject): Promise { + const config = await this.readCurrentConfig(project.path); + if (!config) { + throw new CodingProjectServiceError( + 409, + 'CODING_PROJECT_CONFIG_INVALID', + 'Coding project configuration is unavailable', + ); + } + return config; + } + + private async projectConfigSnapshot( + project: CodingProject, + config: CodingProjectConfigV2, + ): Promise { + return { + project, + config, + knowledgeFiles: await this.listKnowledgeFiles(project.path), + }; + } + private async listKnowledgeFiles(projectPath: string): Promise { try { const entries = await readdir(path.join(projectPath, 'knowledge'), { withFileTypes: true }); diff --git a/src/App.tsx b/src/App.tsx index f1cd4a1..3bd2cb7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -226,7 +226,7 @@ function ProjectChatRoute() { const configResult = await loadProjectConfig(project.id); if (!cancelled) { setResolvedProjectKey(project.id); - setResolvedRoute(configResult.status === 'valid' && Boolean(configResult.config?.initialized) ? 'chat' : 'config'); + setResolvedRoute(configResult.status === 'valid' && Boolean(configResult.config) ? 'chat' : 'config'); } } catch { if (!cancelled) { diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 06c2037..55b3837 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -2,15 +2,11 @@ * Main Layout Component * TitleBar at top, then sidebar + content below. */ -import { Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { Outlet, useLocation } from 'react-router-dom'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { LockKeyhole, Settings2 } from 'lucide-react'; import { Sidebar } from './Sidebar'; import { TitleBar } from './TitleBar'; -import { Button } from '@/components/ui/button'; import { getAiModuleForPath } from '@/lib/ai-modules'; -import { useCodingWorkspaceStore } from '@/stores/coding-workspace'; -import { useProjectConfigStore } from '@/stores/project-config'; import { useSettingsStore } from '@/stores/settings'; import { cn } from '@/lib/utils'; import type { SidebarPeekSource } from './sidebar-peek'; @@ -18,12 +14,8 @@ import type { SidebarPeekSource } from './sidebar-peek'; const SIDEBAR_PEEK_CLOSE_DELAY_MS = 180; export function MainLayout() { - const activeProject = useCodingWorkspaceStore((state) => state.activeProject); const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); - const config = useProjectConfigStore((state) => activeProject ? state.configsByProjectId[activeProject.id] : undefined); - const load = useProjectConfigStore((state) => state.load); const location = useLocation(); - const navigate = useNavigate(); const [sidebarPeekOpen, setSidebarPeekOpen] = useState(false); const sidebarPeekCloseTimerRef = useRef | null>(null); const sidebarPeekSourcesRef = useRef>({ @@ -33,13 +25,9 @@ export function MainLayout() { titlebar: false, }); const activeModule = getAiModuleForPath(location.pathname); - const isProgrammingModule = activeModule === 'programming'; const isPaintingModule = activeModule === 'painting'; const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/'); const isChatWorkspace = location.pathname === '/chat'; - const isInitializationSafeRoute = location.pathname === '/project-config' - || location.pathname === '/plugins' - || !isProgrammingModule; const handleSidebarPeekChange = useCallback((open: boolean, source: SidebarPeekSource) => { if (!sidebarCollapsed) return; @@ -84,11 +72,6 @@ export function MainLayout() { if (sidebarPeekCloseTimerRef.current) clearTimeout(sidebarPeekCloseTimerRef.current); }, []); - useEffect(() => { - if (activeProject && isProgrammingModule) void load(activeProject.id).catch(() => undefined); - }, [activeProject, isProgrammingModule, load]); - - const initializationBlocked = Boolean(activeProject && (!config || !config.initialized) && !isInitializationSafeRoute); return (
{/* Title bar: drag region on macOS, icon + controls on Windows */} @@ -117,16 +100,6 @@ export function MainLayout() { )} > - {initializationBlocked ? ( -
-
- -

项目尚未初始化

-

请先在项目配置中完成至少一个手动 Agent 的名称、模型和职责设置。

- -
-
- ) : null}
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index f5e9b8b..ab6e39a 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -48,11 +48,8 @@ import { useSettingsStore } from '@/stores/settings'; import { useAuthStore, type AuthUser } from '@/stores/auth'; import { useCodingWorkspaceStore } from '@/stores/coding-workspace'; import { useProviderStore } from '@/stores/providers'; -import { isCanonicalCodingProjectId } from '@/lib/coding-projects'; import type { CodingProjectSummary } from '@/types/coding-project'; import { useProjectConfigStore } from '@/stores/project-config'; -import type { ProjectType } from '../../../shared/project-config'; -import type { ProjectIdentityChoice } from '../../../shared/coding-project-contracts'; import { toast } from 'sonner'; type OpenDialogResult = { @@ -72,7 +69,6 @@ type ProjectEntryError = { }; type ProjectDirectoryMode = 'use-selected-directory' | 'create-child-directory'; -type ProjectIdentityKind = ProjectIdentityChoice['kind']; function getFolderName(pathValue: string): string { const trimmed = pathValue.trim(); @@ -166,9 +162,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi const [newProjectName, setNewProjectName] = useState(''); const [newProjectSelectedPath, setNewProjectSelectedPath] = useState(''); const [newProjectDirectoryMode, setNewProjectDirectoryMode] = useState('use-selected-directory'); - const [newProjectType, setNewProjectType] = useState('interactive_ai_app'); - const [newProjectIdentityKind, setNewProjectIdentityKind] = useState('create'); - const [newProjectIdentityProjectId, setNewProjectIdentityProjectId] = useState(''); const [createProjectError, setCreateProjectError] = useState(null); const [creatingProject, setCreatingProject] = useState(false); const [projectEntryError, setProjectEntryError] = useState(null); @@ -186,7 +179,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi const isPaintingModule = activeModule === 'painting'; const isRobotModule = activeModule === 'robot'; const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/'); - const projectConfigPath = '/project-config'; const visibleProjects = projects; const selectedProjectFolderName = getFolderName(newProjectSelectedPath); const accountName = userProfile?.displayName?.trim() || getAuthUserDisplayName(authUser) || '未登录用户'; @@ -397,9 +389,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi setNewProjectName(''); setNewProjectSelectedPath(''); setNewProjectDirectoryMode('use-selected-directory'); - setNewProjectType('interactive_ai_app'); - setNewProjectIdentityKind('create'); - setNewProjectIdentityProjectId(''); setCreateProjectError(null); setCreateDialogOpen(true); }; @@ -407,9 +396,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi const closeCreateProject = () => { if (creatingProject) return; setCreateDialogOpen(false); - setNewProjectType('interactive_ai_app'); - setNewProjectIdentityKind('create'); - setNewProjectIdentityProjectId(''); setCreateProjectError(null); }; @@ -436,9 +422,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi return; } await setActiveProject(project.id); - navigate(result.config.initialized - ? '/chat' - : projectConfigPath); + navigate('/chat'); }; const readAndEnterProject = async (project: CodingProjectSummary) => { @@ -462,20 +446,12 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi setCreateProjectError('请选择项目路径'); return; } - const boundProjectId = newProjectIdentityProjectId.trim(); - if (newProjectIdentityKind === 'bind' && !isCanonicalCodingProjectId(boundProjectId)) { - setCreateProjectError('请输入有效的项目 ID(小写 UUID)。'); - return; - } - setCreatingProject(true); setCreateProjectError(null); try { const project = await createProject({ - projectType: newProjectType, - identity: newProjectIdentityKind === 'create' - ? { kind: 'create' } - : { kind: 'bind', projectId: boundProjectId }, + projectType: 'interactive_ai_app', + identity: { kind: 'create' }, ...(newProjectDirectoryMode === 'create-child-directory' ? { parentPath: selectedPath, projectName } : { projectPath: selectedPath }), @@ -756,7 +732,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
新建项目 - 默认直接使用所选文件夹,也可以在所选位置新建下级文件夹。 + 只需选择项目文件夹,其他设置会自动准备好。
+ ) : null} + + + ) : (
)} diff --git a/src/pages/ProjectConfiguration/index.tsx b/src/pages/ProjectConfiguration/index.tsx index 1e86629..20c9200 100644 --- a/src/pages/ProjectConfiguration/index.tsx +++ b/src/pages/ProjectConfiguration/index.tsx @@ -11,20 +11,16 @@ import { ProjectPublishAction } from '@/components/works/ProjectPublishAction'; import { buildCodingModelOptions, parseCodingModelKey, type CodingModelOption } from '@/lib/coding-model-options'; import { getCodingSkills } from '@/lib/coding-product-tools'; import { abortCodingConversation } from '@/lib/coding-conversations'; -import { isCanonicalCodingProjectId } from '@/lib/coding-projects'; import { getSkillDisplayInfo } from '@/lib/skill-display'; import { getAgentAvatarSrc } from '@/lib/agent-avatars'; -import { cn } from '@/lib/utils'; import { codingConversationStore } from '@/stores/coding-conversations'; import { useCodingWorkspaceStore } from '@/stores/coding-workspace'; import { useProjectConfigStore } from '@/stores/project-config'; import { useProviderStore } from '@/stores/providers'; import type { CodingProjectAgent, CodingProjectConfig } from '@/types/coding-project'; -import type { ProjectIdentityChoice } from '../../../shared/coding-project-contracts'; const EMPTY_FILES: string[] = []; type DrawerMode = 'models' | 'knowledge' | 'skills' | null; -type ProjectIdentityKind = ProjectIdentityChoice['kind']; type SkillStructureEntry = { path: string; type: 'file' | 'directory' }; type SkillInfo = { id: string; @@ -134,8 +130,6 @@ export function ProjectConfiguration() { const knowledge = useProjectConfigStore((state) => activeProject ? state.knowledgeByProjectId[activeProject.id] ?? EMPTY_FILES : EMPTY_FILES); const load = useProjectConfigStore((state) => state.load); const save = useProjectConfigStore((state) => state.save); - const resolveIdentity = useProjectConfigStore((state) => state.resolveIdentity); - const makeIndependentCopy = useProjectConfigStore((state) => state.makeIndependentCopy); const uploadKnowledge = useProjectConfigStore((state) => state.uploadKnowledge); const removeProjectConfig = useProjectConfigStore((state) => state.remove); const providerAccounts = useProviderStore((state) => state.accounts); @@ -147,11 +141,6 @@ export function ProjectConfiguration() { const [activeAgentId, setActiveAgentId] = useState(null); const [skills, setSkills] = useState([]); const [saving, setSaving] = useState(false); - const [identityKind, setIdentityKind] = useState('create'); - const [identityProjectId, setIdentityProjectId] = useState(''); - const [identitySaving, setIdentitySaving] = useState(false); - const [identityError, setIdentityError] = useState(null); - const [independentCopyDialogOpen, setIndependentCopyDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [partnerDialogOpen, setPartnerDialogOpen] = useState(false); const [archiveAgentId, setArchiveAgentId] = useState(null); @@ -174,11 +163,6 @@ export function ProjectConfiguration() { useEffect(() => { if (activeProject) void load(activeProject.id); }, [activeProject, load]); useEffect(() => { if (stored) setDraft(structuredClone(stored)); }, [stored]); - useEffect(() => { - setIdentityKind('create'); - setIdentityProjectId(''); - setIdentityError(null); - }, [activeProject?.id, draft?.projectId]); useEffect(() => { const requestedAgentId = new URLSearchParams(location.search).get('agent'); if (!requestedAgentId || !draft?.agents.some((agent) => agent.id === requestedAgentId)) return; @@ -263,46 +247,13 @@ export function ProjectConfiguration() { await save(activeProject.id, { ...draft, initialized: true }); await reloadWorkspace(); playSaveConfirmation(); - toast.success(draft.initialized ? '项目配置已更新' : '项目初始化完成'); + toast.success('项目配置已保存'); } catch (error) { toast.error(error instanceof Error ? error.message : String(error)); } finally { setSaving(false); } }; - const handleResolveIdentity = async () => { - const boundProjectId = identityProjectId.trim(); - if (identityKind === 'bind' && !isCanonicalCodingProjectId(boundProjectId)) { - setIdentityError('请输入有效的项目 ID(小写 UUID)。'); - return; - } - setIdentitySaving(true); - setIdentityError(null); - try { - await resolveIdentity(activeProject.id, identityKind === 'create' - ? { kind: 'create' } - : { kind: 'bind', projectId: boundProjectId }); - await reloadWorkspace(); - toast.success('项目身份已设置,之后不可通过普通保存修改。'); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setIdentityError(message); - toast.error(message); - } finally { - setIdentitySaving(false); - } - }; - const handleIndependentCopy = async () => { - setIdentitySaving(true); - try { - await makeIndependentCopy(activeProject.id); - await reloadWorkspace(); - setIndependentCopyDialogOpen(false); - toast.success('已设为独立副本,当前文件夹已获得新的项目 ID。'); - } finally { - setIdentitySaving(false); - } - }; const handleKnowledge = async (file?: File) => { if (!file) return; try { await uploadKnowledge(activeProject.id, file); toast.success(`已上传到 knowledge/${file.name}`); } catch (error) { toast.error(error instanceof Error ? error.message : String(error)); } }; const handleDeleteProject = async () => { await removeProject(activeProject.id); @@ -341,120 +292,6 @@ export function ProjectConfiguration() {

项目配置

-
- {draft.projectId ? ( -
-
-

项目身份

-

当前项目 ID

- {draft.projectId} -

- 同一账号绑定相同项目 ID 会共享开发数据;项目 ID 不是凭据。 -

-
- -
- ) : ( -
{ - event.preventDefault(); - void handleResolveIdentity(); - }} - > -
-

为旧项目设置身份

-

- 这是一个没有项目 ID 的旧项目。此操作只会为当前文件夹设置一次身份;项目文件和云端数据都不会被复制。 -

-
-
- 选择项目 ID -
- - -
-
- {identityKind === 'bind' ? ( -
- - { - setIdentityProjectId(event.target.value); - setIdentityError(null); - }} - placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" - autoComplete="off" - spellCheck={false} - className="w-full rounded-md border border-foreground/15 bg-white px-3 py-2 font-mono text-sm font-medium outline-none focus:ring-2 focus:ring-brand/20" - disabled={identitySaving} - /> -

- 同一账号绑定相同项目 ID 会共享开发数据;项目 ID 不是凭据。 -

-
- ) : null} - {identityError ?

{identityError}

: null} -
- -
-
- )} -
} title="可用模型" subtitle={`${modelOptions.length} 个可用模型`} onClick={() => setDrawerMode('models')} />} title="可用技能" subtitle={`${skills.length} 项可用技能`} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} />} title="知识文件" subtitle={`${knowledge.length} 个知识文件`} onClick={() => setDrawerMode('knowledge')} />

项目智能体

智能体配置仅属于当前项目;每个智能体可以拥有多条独立对话。

{draft.agents.filter((agent) => !agent.archivedAt).map((agent) => { setActiveAgentId(agent.id); setPartnerDialogOpen(true); }} />)}{draft.agents.every((agent) => agent.archivedAt) ?
当前项目还没有智能体,点击右上角的 + 创建。
: null}
{draft.agents.some((agent) => agent.archivedAt) ?

已归档

{draft.agents.filter((agent) => agent.archivedAt).map((agent) => )}
: null}
@@ -469,7 +306,7 @@ export function ProjectConfiguration() { buttonSize="sm" /> ) : null} - +
@@ -513,16 +350,6 @@ export function ProjectConfiguration() { onConfirm={handleDeleteProject} onError={(error) => toast.error(error instanceof Error ? error.message : String(error))} /> - setIndependentCopyDialogOpen(false)} - onConfirm={handleIndependentCopy} - onError={(error) => toast.error(error instanceof Error ? error.message : String(error))} - /> ; } diff --git a/tests/e2e/coding-project-identity.spec.ts b/tests/e2e/coding-project-identity.spec.ts index 182be06..6e5e054 100644 --- a/tests/e2e/coding-project-identity.spec.ts +++ b/tests/e2e/coding-project-identity.spec.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; -const BOUND_PROJECT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const CANONICAL_PROJECT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u; async function readProjectConfig(projectPath: string): Promise> { return JSON.parse(await readFile(path.join(projectPath, '.makelore', 'project.json'), 'utf8')) as Record; @@ -25,90 +25,50 @@ async function selectProgrammingProjectFolder( } test.describe('Coding project identity UX', () => { - test('supports default create, bind validation, cancellation, legacy resolution, and independent copies', async ({ launchElectronApp }) => { - const projectPath = await mkdtemp(path.join(tmpdir(), 'niancode-identity-e2e-')); + test('keeps the service identity automatic and invisible to young users', async ({ launchElectronApp }) => { + const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-identity-e2e-')); const app = await launchElectronApp({ skipSetup: true }); try { const page = await selectProgrammingProjectFolder(app, projectPath); await page.getByTestId('sidebar-create-project').click(); const createDialog = page.getByRole('dialog', { name: '新建项目' }); - await expect(createDialog.getByRole('radio', { name: '创建新的项目 ID' })).toBeChecked(); - await createDialog.getByRole('radio', { name: '绑定已有项目 ID' }).click(); - await createDialog.getByRole('textbox', { name: '已有项目 ID' }).fill('not-a-project-id'); + await expect(createDialog.getByText(/项目 ID|项目身份|独立副本/u)).toHaveCount(0); + await expect(createDialog.getByRole('radio', { name: '创建新的项目 ID' })).toHaveCount(0); + await expect(createDialog.getByRole('radio', { name: '绑定已有项目 ID' })).toHaveCount(0); await createDialog.getByRole('button', { name: '选择路径' }).click(); + await expect(createDialog.getByLabel('项目路径')).toHaveValue(projectPath); await createDialog.getByRole('button', { name: '确认创建' }).click(); - await expect(createDialog).toContainText('请输入有效的项目 ID(小写 UUID)。'); - await createDialog.getByRole('button', { name: '取消' }).first().click(); - await expect(page.getByRole('dialog', { name: '新建项目' })).toHaveCount(0); - await page.getByTestId('sidebar-create-project').click(); - const defaultCreateDialog = page.getByRole('dialog', { name: '新建项目' }); - await expect(defaultCreateDialog.getByRole('radio', { name: '创建新的项目 ID' })).toBeChecked(); - await defaultCreateDialog.getByRole('button', { name: '选择路径' }).click(); - await expect(defaultCreateDialog.getByLabel('项目路径')).toHaveValue(projectPath); - await defaultCreateDialog.getByRole('button', { name: '确认创建' }).click(); - - await expect(page.getByTestId('project-configuration-page')).toBeVisible(); + await expect(page.getByTestId('coding-chat-empty-agent')).toBeVisible(); const createdConfig = await readProjectConfig(projectPath); - const generatedProjectId = createdConfig.projectId; - expect(generatedProjectId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u); - await expect(page.getByTestId('project-identity-value')).toHaveText(String(generatedProjectId)); - await expect(page.getByText('同一账号绑定相同项目 ID 会共享开发数据;项目 ID 不是凭据。')).toBeVisible(); + const generatedProjectId = String(createdConfig.projectId); + expect(generatedProjectId).toMatch(CANONICAL_PROJECT_ID); - await writeFile(path.join(projectPath, 'identity-sentinel.txt'), 'keep this file in place'); - const independentButton = page.getByTestId('independent-copy-button'); - await independentButton.click(); - const independentDialog = page.getByRole('dialog', { name: '设为独立副本?' }); - await expect(independentDialog).toContainText('不会移动或复制文件'); - await expect(independentDialog).toContainText('不会复制云端开发数据'); - await independentDialog.getByRole('button', { name: '取消' }).click(); - await expect(independentDialog).toHaveCount(0); - expect((await readProjectConfig(projectPath)).projectId).toBe(generatedProjectId); - - await independentButton.click(); - await page.getByRole('dialog', { name: '设为独立副本?' }).getByRole('button', { name: '确认设为独立副本' }).click(); - await expect(page.getByTestId('project-identity-value')).not.toHaveText(String(generatedProjectId)); - const independentConfig = await readProjectConfig(projectPath); - expect(independentConfig.projectId).not.toBe(generatedProjectId); - expect(await readFile(path.join(projectPath, 'identity-sentinel.txt'), 'utf8')).toBe('keep this file in place'); + await page.getByRole('button', { name: '创建项目智能体' }).click(); + await expect(page.getByTestId('project-configuration-page')).toBeVisible(); + await expect(page.getByTestId('project-identity-card')).toHaveCount(0); + await expect(page.getByTestId('legacy-project-identity-card')).toHaveCount(0); + await expect(page.getByTestId('independent-copy-button')).toHaveCount(0); + await expect(page.getByText(/项目 ID|项目身份|独立副本/u)).toHaveCount(0); const legacyConfig = await readProjectConfig(projectPath); delete legacyConfig.projectId; - await writeFile(path.join(projectPath, '.makelore', 'project.json'), JSON.stringify(legacyConfig, null, 2)); + await writeFile( + path.join(projectPath, '.makelore', 'project.json'), + JSON.stringify(legacyConfig, null, 2), + ); await page.reload(); - await expect(page.getByTestId('legacy-project-identity-card')).toBeVisible(); - - const legacyCard = page.getByTestId('legacy-project-identity-card'); - await legacyCard.getByRole('radio', { name: '绑定已有项目 ID' }).click(); - await legacyCard.getByRole('textbox', { name: '已有项目 ID' }).fill(BOUND_PROJECT_ID); - await legacyCard.getByTestId('resolve-project-identity-button').click(); - await expect(page.getByTestId('project-identity-value')).toHaveText(BOUND_PROJECT_ID); - await expect(page.getByTestId('legacy-project-identity-card')).toHaveCount(0); - } finally { - await closeElectronApp(app); - await rm(projectPath, { recursive: true, force: true }); - } - }); - - test('creates a project bound to an explicit canonical project ID', async ({ launchElectronApp }) => { - const projectPath = await mkdtemp(path.join(tmpdir(), 'niancode-bound-identity-e2e-')); - const app = await launchElectronApp({ skipSetup: true }); - - try { - const page = await selectProgrammingProjectFolder(app, projectPath); - await page.getByTestId('sidebar-create-project').click(); - const createDialog = page.getByRole('dialog', { name: '新建项目' }); - await createDialog.getByRole('radio', { name: '绑定已有项目 ID' }).click(); - await expect(createDialog.getByText('同一账号绑定相同项目 ID 会共享开发数据;项目 ID 不是凭据。')).toBeVisible(); - await createDialog.getByRole('textbox', { name: '已有项目 ID' }).fill(BOUND_PROJECT_ID); - await createDialog.getByRole('button', { name: '选择路径' }).click(); - await createDialog.getByRole('button', { name: '确认创建' }).click(); await expect(page.getByTestId('project-configuration-page')).toBeVisible(); - await expect(page.getByTestId('project-identity-value')).toHaveText(BOUND_PROJECT_ID); - expect((await readProjectConfig(projectPath)).projectId).toBe(BOUND_PROJECT_ID); + const repairedConfig = await readProjectConfig(projectPath); + const repairedProjectId = String(repairedConfig.projectId); + expect(repairedProjectId).toMatch(CANONICAL_PROJECT_ID); + expect(repairedProjectId).not.toBe(generatedProjectId); + await expect(page.getByTestId('project-identity-card')).toHaveCount(0); + await expect(page.getByTestId('legacy-project-identity-card')).toHaveCount(0); + await expect(page.getByText(/项目 ID|项目身份|独立副本/u)).toHaveCount(0); } finally { await closeElectronApp(app); await rm(projectPath, { recursive: true, force: true }); diff --git a/tests/e2e/project-configuration-skills.spec.ts b/tests/e2e/project-configuration-skills.spec.ts index 5101eba..4cca7ad 100644 --- a/tests/e2e/project-configuration-skills.spec.ts +++ b/tests/e2e/project-configuration-skills.spec.ts @@ -20,8 +20,10 @@ test.describe('Project configuration skills', () => { await page.getByTestId('ai-module-option-programming').click(); await expect(page.getByTestId('main-layout')).toBeVisible(); await page.getByTestId('sidebar-create-project').click(); - await expect(page.getByRole('radio', { name: '交互式 AI 应用' })).toBeChecked(); - await expect(page.getByRole('radio', { name: '自定义项目' })).not.toBeChecked(); + await expect(page.getByRole('radio', { name: '创建新的项目 ID' })).toHaveCount(0); + await expect(page.getByRole('radio', { name: '绑定已有项目 ID' })).toHaveCount(0); + await expect(page.getByRole('radio', { name: '交互式 AI 应用' })).toHaveCount(0); + await expect(page.getByRole('radio', { name: '自定义项目' })).toHaveCount(0); await expect(page.getByRole('radio', { name: '小游戏' })).toHaveCount(0); await expect(page.getByRole('radio', { name: '小程序' })).toHaveCount(0); await expect(page.getByRole('radio', { name: '直接使用所选文件夹(默认)' })).toBeChecked(); @@ -29,7 +31,16 @@ test.describe('Project configuration skills', () => { await expect(page.getByLabel('项目路径')).toHaveValue(parentPath); await page.getByRole('button', { name: '确认创建' }).click(); + await expect(page.getByTestId('coding-chat-empty-agent')).toBeVisible(); + await expect(page.getByTestId('project-initialization-gate')).toHaveCount(0); + await page.getByRole('button', { name: '创建项目智能体' }).click(); await expect(page.getByTestId('project-configuration-page')).toBeVisible(); + await expect(page.getByTestId('project-identity-card')).toHaveCount(0); + await expect(page.getByTestId('legacy-project-identity-card')).toHaveCount(0); + await expect(page.getByTestId('independent-copy-button')).toHaveCount(0); + await expect(page.getByText(/项目 ID|项目身份|独立副本/u)).toHaveCount(0); + await expect(page.getByRole('button', { name: '保存项目配置' })).toBeVisible(); + await expect(page.getByRole('button', { name: '确认并完成初始化' })).toHaveCount(0); await expect(page.getByTestId('sidebar-nav-publish')).toHaveCount(0); await expect(page.getByText(/新项目还没有智能体/)).toHaveCount(0); await expect(page.getByTestId('project-configuration-actions')).toBeVisible(); @@ -96,6 +107,9 @@ test.describe('Project configuration skills', () => { }, null, 2)); await page.reload(); await expect(page.getByTestId('project-configuration-page')).toBeVisible(); + await expect(page.getByTestId('project-identity-card')).toHaveCount(0); + await expect(page.getByTestId('legacy-project-identity-card')).toHaveCount(0); + await expect(page.getByText(/项目 ID|项目身份|独立副本/u)).toHaveCount(0); await page.getByTestId('resource-card-skills').click(); await expect(page.getByTestId('superpowers-card')).toHaveCount(0); diff --git a/tests/unit/app-module-provider-gate.test.tsx b/tests/unit/app-module-provider-gate.test.tsx index edc7385..9bd7fc6 100644 --- a/tests/unit/app-module-provider-gate.test.tsx +++ b/tests/unit/app-module-provider-gate.test.tsx @@ -6,6 +6,9 @@ import { useAuthStore } from '@/stores/auth'; import { useProviderStore } from '@/stores/providers'; import { useSettingsStore } from '@/stores/settings'; import { useUserSyncStore } from '@/stores/user-sync'; +import { codingWorkspaceStore } from '@/stores/coding-workspace'; +import { useProjectConfigStore } from '@/stores/project-config'; +import { createCodingProjectConfigV2 } from '@electron/coding-projects/project-config'; vi.mock('@/components/layout/MainLayout', () => ({ MainLayout: () => ( @@ -41,6 +44,13 @@ vi.mock('@/pages/Settings', () => ({ describe('App programming provider initialization gate', () => { const initProviders = vi.fn(); + const project = { + id: 'local-project', + name: 'Local project', + createdAt: '2026-09-06T00:00:00.000Z', + updatedAt: '2026-09-06T00:00:00.000Z', + lastOpenedAt: '2026-09-06T00:00:00.000Z', + }; beforeEach(() => { vi.clearAllMocks(); @@ -49,6 +59,18 @@ describe('App programming provider initialization gate', () => { init: vi.fn(), }); useProviderStore.setState({ init: initProviders }); + codingWorkspaceStore.setState({ + activeProjectId: project.id, + activeProject: project, + load: vi.fn().mockResolvedValue(undefined), + }); + useProjectConfigStore.setState({ + load: vi.fn().mockResolvedValue({ + status: 'valid', + config: { ...createCodingProjectConfigV2(), initialized: false }, + knowledgeFiles: [], + }), + }); useAuthStore.setState({ initialized: true, accessToken: 'access-token', @@ -94,6 +116,13 @@ describe('App programming provider initialization gate', () => { await waitFor(() => expect(initProviders).toHaveBeenCalledTimes(1)); }); + it('opens a valid project without requiring the legacy initialized flag', async () => { + await renderAt('/chat'); + + expect(await screen.findByText('Programming workspace')).toBeInTheDocument(); + expect(screen.queryByText('项目尚未初始化')).not.toBeInTheDocument(); + }); + it('redirects an orphaned token without a user identity away from a direct workspace route', async () => { useAuthStore.setState({ initialized: true, diff --git a/tests/unit/coding-chat-panel.test.tsx b/tests/unit/coding-chat-panel.test.tsx index 28b85cf..9a04bf3 100644 --- a/tests/unit/coding-chat-panel.test.tsx +++ b/tests/unit/coding-chat-panel.test.tsx @@ -199,6 +199,24 @@ describe('CodingChatPanel first Conversation', () => { vi.resetModules(); }); + it('keeps a project with no Agent accessible and offers optional Agent setup', async () => { + const openProjectSettings = vi.fn(); + projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); + projectApi.config.mockResolvedValue({ + project, + config: { ...configForAgents([]), initialized: false }, + }); + projectApi.conversations.mockResolvedValue([]); + const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); + + render(); + + expect(await screen.findByTestId('coding-chat-empty-agent')).toHaveTextContent('项目已准备好'); + expect(projectApi.create).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '创建项目智能体' })); + expect(openProjectSettings).toHaveBeenCalledOnce(); + }); + it('makes the first-Conversation textarea editable while runtime metadata is held', async () => { projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); projectApi.config.mockResolvedValue({ project, config }); diff --git a/tests/unit/coding-core-routes.test.ts b/tests/unit/coding-core-routes.test.ts index adafc90..585d073 100644 --- a/tests/unit/coding-core-routes.test.ts +++ b/tests/unit/coding-core-routes.test.ts @@ -736,11 +736,11 @@ describe('PI-100 coding core Host contract', () => { it('maps persistence failures to a stable fixed Host error', async () => { const result = await setup(); + const current = await result.projects.getConfig('project-a'); const failingProjects = new CodingProjectService(result.store, { writeConfig: async () => { throw new Error(`secret disk path=${result.root}`); }, }); const failingConversations = new CodingConversationService(failingProjects, result.runtime); - const current = await failingProjects.getConfig('project-a'); const response = await dispatchHostApiRequest(context({ ...result, projects: failingProjects, diff --git a/tests/unit/coding-project-identity.test.ts b/tests/unit/coding-project-identity.test.ts index 03d7711..0705449 100644 --- a/tests/unit/coding-project-identity.test.ts +++ b/tests/unit/coding-project-identity.test.ts @@ -174,6 +174,36 @@ describe('coding project durable identity', () => { expect((await projects.getConfig(local.project.id)).config.projectId).toBe(PROJECT_ID); }); + it('automatically assigns one identity when a legacy project is read concurrently', async () => { + const projectPath = await makeRoot(); + const store = makeStore(); + const local = await createLocalCodingProject({ projectPath, now: CREATED }, store); + const createProjectId = vi.fn(() => PROJECT_ID); + const events: string[] = []; + const projects = new CodingProjectService(store, { + createProjectId, + now: () => UPDATED, + onProjectIdentityChanging: () => { events.push('invalidate'); }, + writeConfig: async (filePath, value) => { + events.push('write'); + await writeCodingProjectConfigV2(filePath, value); + }, + onResourcesChanged: () => { events.push('resources'); }, + }); + + const [first, second] = await Promise.all([ + projects.getConfig(local.project.id), + projects.getConfig(local.project.id), + ]); + + expect(first.config.projectId).toBe(PROJECT_ID); + expect(second.config.projectId).toBe(PROJECT_ID); + expect(createProjectId).toHaveBeenCalledTimes(1); + expect(events).toEqual(['invalidate', 'write', 'resources']); + await expect(readCodingProjectConfigV2(projectPath)) + .resolves.toMatchObject({ status: 'valid', config: { projectId: PROJECT_ID, updatedAt: UPDATED } }); + }); + it('keeps ordinary saves immutable and supports a confirmed independent copy', async () => { const projectPath = await makeRoot(); const store = makeStore(); @@ -280,8 +310,13 @@ describe('coding project durable identity', () => { const legacyPath = await makeRoot('makelore-project-identity-legacy-'); const legacyStore = makeStore(); await createLocalCodingProject({ projectPath: legacyPath }, legacyStore); - await expect(new CodingProjectService(legacyStore).requireActiveRealProjectWithIdentity()) - .rejects.toMatchObject({ code: 'CODING_PROJECT_IDENTITY_REQUIRED', status: 409 }); + const legacyProjects = new CodingProjectService(legacyStore, { + createProjectId: () => NEXT_PROJECT_ID, + }); + await expect(legacyProjects.requireActiveRealProjectWithIdentity()) + .resolves.toMatchObject({ projectId: NEXT_PROJECT_ID }); + await expect(readCodingProjectConfigV2(legacyPath)) + .resolves.toMatchObject({ status: 'valid', config: { projectId: NEXT_PROJECT_ID } }); }); it('exposes identity resolution and independent-copy through the Host routes', async () => { diff --git a/tests/unit/main-layout-module-gate.test.tsx b/tests/unit/main-layout-module-gate.test.tsx index 37bb229..eda5624 100644 --- a/tests/unit/main-layout-module-gate.test.tsx +++ b/tests/unit/main-layout-module-gate.test.tsx @@ -2,10 +2,7 @@ import { render, screen } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MainLayout } from '@/components/layout/MainLayout'; -import { codingWorkspaceStore } from '@/stores/coding-workspace'; -import { useProjectConfigStore } from '@/stores/project-config'; import { useSettingsStore } from '@/stores/settings'; -import { createCodingProjectConfigV2 } from '@electron/coding-projects/project-config'; const routeRenderMock = vi.hoisted(() => vi.fn()); @@ -46,20 +43,6 @@ function renderLayout(path: string) { describe('MainLayout module isolation', () => { beforeEach(() => { routeRenderMock.mockReset(); - const project = { - id: 'local-project', - path: '/tmp/local-project', - name: 'local-project', - createdAt: '', - updatedAt: '', - lastOpenedAt: '', - }; - codingWorkspaceStore.setState({ activeProject: project }); - useProjectConfigStore.setState({ - configsByProjectId: { - [project.id]: { ...createCodingProjectConfigV2(), initialized: false }, - }, - }); useSettingsStore.setState({ sidebarCollapsed: false }); }); @@ -89,19 +72,17 @@ describe('MainLayout module isolation', () => { }); it('does not overlay the local project initialization gate on AI hardware', () => { - const load = vi.fn().mockResolvedValue(undefined); - useProjectConfigStore.setState({ load }); renderLayout('/ai-hardware'); expect(screen.getByTestId('route-content')).toBeVisible(); expect(screen.queryByTestId('project-initialization-gate')).not.toBeInTheDocument(); - expect(load).not.toHaveBeenCalled(); }); - it('keeps the local project initialization gate for AI programming routes', () => { + it('does not restore the removed project initialization gate on AI programming routes', () => { renderLayout('/chat'); - expect(screen.getByTestId('project-initialization-gate')).toBeVisible(); + expect(screen.getByTestId('route-content')).toBeVisible(); + expect(screen.queryByTestId('project-initialization-gate')).not.toBeInTheDocument(); expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-overlay', 'false'); }); From be1764e0166c5228af19c3cc17c8dd2be69ada20 Mon Sep 17 00:00:00 2001 From: inman Date: Mon, 7 Sep 2026 10:06:37 +0800 Subject: [PATCH 15/22] feat: move plugin workspace into project settings --- ...4e2__project-settings-plugin-navigation.md | 86 +++++++++++++++ ...-move-plugins-project-settings-7c91a4e2.md | 100 ++++++++++++++++++ README.md | 2 +- scripts/lib/pi-product-artifact.mjs | 2 +- src/App.tsx | 10 +- src/components/layout/MainLayout.tsx | 1 + src/components/layout/Sidebar.tsx | 18 ---- src/lib/ai-modules.ts | 1 + src/pages/Plugins/index.tsx | 57 +++++++--- src/pages/Plugins/legacy-plugin-redirect.tsx | 21 +++- src/pages/ProjectConfiguration/index.tsx | 32 +++++- tests/e2e/plugin-marketplace.spec.ts | 17 ++- tests/e2e/project-plugins.spec.ts | 8 +- tests/unit/login-page.test.tsx | 6 +- tests/unit/main-layout-module-gate.test.tsx | 4 +- tests/unit/pi-product-artifact.test.ts | 6 +- tests/unit/plugin-navigation.test.tsx | 38 +++++-- .../unit/plugin-workspace-controller.test.ts | 4 +- tests/unit/plugins-page.test.tsx | 26 +++-- 19 files changed, 363 insertions(+), 76 deletions(-) create mode 100644 .project-docs/10-decisions/proposals/20260906-move-plugins-project-settings-7c91a4e2__project-settings-plugin-navigation.md create mode 100644 .project-docs/30-worklog/tasks/20260906-move-plugins-project-settings-7c91a4e2.md diff --git a/.project-docs/10-decisions/proposals/20260906-move-plugins-project-settings-7c91a4e2__project-settings-plugin-navigation.md b/.project-docs/10-decisions/proposals/20260906-move-plugins-project-settings-7c91a4e2__project-settings-plugin-navigation.md new file mode 100644 index 0000000..1258205 --- /dev/null +++ b/.project-docs/10-decisions/proposals/20260906-move-plugins-project-settings-7c91a4e2__project-settings-plugin-navigation.md @@ -0,0 +1,86 @@ +# Project Configuration owns Plugin navigation + +## Status + +Proposed for integration; implemented and verified in task +`20260906-move-plugins-project-settings-7c91a4e2`. + +## Date + +2026-09-06 + +## Context + +The integrated product exposed one reviewed unified Plugin workspace at +`/plugins` through a standalone Code sidebar entry. Project Configuration no +longer contained its predecessor Plugin Services sheet. The user first directed +that Plugins belong in Project Settings, then clarified that the workspace must +live on the same project page used to configure Agents and Skills rather than +replace that page after navigation. + +The navigation change must not collapse the existing independent lifecycles for +Marketplace acquisition, device installation, project enablement, Agent Skill +assignment, runtime authorization, and billing. It also must not restore the +retired duplicate sheet or move Main-owned Plugin authority into Renderer code. + +## Decision + +- The Agent/Skill Project Configuration page is the visible owner of the single + unified Plugin workspace. +- Its `插件` resource card opens the workspace in a wide same-page drawer. The + Project Configuration page remains mounted behind it. +- `/project-config/plugins` is the canonical deep link for the drawer-open state; + it renders Project Configuration rather than a standalone Plugin page. +- `/plugins` preserves query and navigation state while redirecting to the + canonical URL. `/plugin-marketplace`, `/my-plugins`, and `/project-plugins` + remain deterministic filtered compatibility redirects. +- The Code sidebar has no standalone Plugin entry. +- The canonical workspace remains accessible before Code Provider initialization + and when no project is active, so Marketplace and account/device views do not + incorrectly depend on local project selection. +- The existing unified controller, data sources, mutation semantics, + authentication, and lifecycle separation remain unchanged. + +## Rationale + +Plugin installation and activation configure resources that projects may use, so +Project Configuration is the clearer navigation owner. Reusing the unified +workspace avoids competing product surfaces and preserves one authoritative view +across Marketplace, Account Library, device packages, and project status. +Compatibility redirects protect existing deep links and authenticated return +flows while establishing an unambiguous canonical location. + +## Consequences + +- Project Configuration gains a Plugin card beside models, Skills, and knowledge, + with access retained in the no-active-project state. +- Closing the Plugin drawer returns to the already-mounted Project Configuration + page; Agent assignment actions close the drawer to expose that same page. +- Existing bookmarks continue to work, but tests and new product links should use + `/project-config/plugins`. +- Canonical project memory that names the Code sidebar or `/plugins` as the sole + current entry must be superseded during Integration Mode. +- No backend contract, package trust boundary, runtime permission, billing rule, + or native Web Search behavior changes. + +## Supersedes + +- The navigation-only claim that the Code sidebar owns the single `插件` entry and + `/plugins` is the canonical product route. +- It does not supersede the retirement of the duplicate Project Configuration + Plugin Services sheet; that implementation remains retired. + +## Evidence + +- User direction: “插件应该在项目设置中”, clarified as “是放在配置agent、skill那个项目页”. +- Focused unit verification: 62/62 passed. +- Electron Plugin journeys: 4/4 passed. +- Full unit, typecheck, lint, and Vite production build passed. + +## Related + +- `.project-docs/10-decisions/adr-008-interactive-ai-app-scaffold.md` +- `.project-docs/30-worklog/tasks/20260906-move-plugins-project-settings-7c91a4e2.md` +- `src/pages/ProjectConfiguration/index.tsx` +- `src/pages/Plugins/index.tsx` +- `src/pages/Plugins/legacy-plugin-redirect.tsx` diff --git a/.project-docs/30-worklog/tasks/20260906-move-plugins-project-settings-7c91a4e2.md b/.project-docs/30-worklog/tasks/20260906-move-plugins-project-settings-7c91a4e2.md new file mode 100644 index 0000000..7b110b5 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260906-move-plugins-project-settings-7c91a4e2.md @@ -0,0 +1,100 @@ +# Task: Move plugins into project settings + +## Identity + +- Task ID: 20260906-move-plugins-project-settings-7c91a4e2 +- Mode: Feature +- Branch: codex/20260906-move-plugins-project-settings-7c91a4e2-move-plugins-project-settings +- Worktree: /Users/inmanx/Documents/makelore-task-move-plugins-project-settings-7c91a4e2 +- Base commit: f8eee430f4f4167dc2ecc682ece96c652cdde14b +- Owner: codex +- Status: Ready for integration + +## Scope + +- Move the single user-visible Plugin workspace from the Code sidebar directly + into the same Project Configuration page used to configure Agents, Skills, + models, and knowledge. +- Make `/project-config/plugins` the canonical Plugin URL while retaining + deterministic compatibility redirects from `/plugins`, `/plugin-marketplace`, + `/my-plugins`, and `/project-plugins`. +- Update focused unit and Electron E2E coverage, the packaged-product marker, and + README current-state wording for the navigation change. + +## Intent And Constraints + +- Preserve the existing unified Plugin workspace and its projections of + Marketplace, Account Library, official-device state, Device Packages, current + project state, and retained IDs; do not restore the retired duplicate Plugin + Services sheet. +- Keep acquisition, device installation, project enablement, Agent Skill + assignment, runtime authorization, and billing as separate lifecycles. +- Preserve Main-owned Host API, authentication, package verification, and runtime + authority. Renderer navigation must not introduce direct IPC or local-runtime + HTTP access. +- Keep Project Configuration and its nested Plugin route available before Code + Provider initialization, including the no-active-project state. +- Render the nested route as a same-page Project Configuration drawer, not as a + replacement standalone Plugin page. +- Preserve legacy deep-link query/state and authenticated return paths. +- Keep the single Makelore light visual system and Chinese product copy. + +## Outcome + +- Added a `插件` ResourceCard alongside models, Skills, and knowledge in Project + Configuration, retained access in the no-active-project state, and removed the + standalone Code sidebar Plugin entry. +- Changed `/project-config/plugins` to render the Project Configuration page with + its wide Plugin drawer open. The Agent/Skill project page remains mounted behind + the drawer; closing it returns to the same page. Query/state-preserving + `/plugins` compatibility and existing filtered legacy redirects remain intact. +- Reused the unified Plugin controller and view inside the drawer without + restoring the retired duplicate Plugin Services implementation. +- Kept the unified Plugin controller, data sources, mutations, authentication, + and lifecycle semantics unchanged. +- Updated initialization gates, login return path, README, packaged Marketplace + artifact assertion, focused unit tests, and Electron E2E journeys. + +## Verification + +- Focused navigation, layout, embedded Plugin, controller, login, and artifact + verification — 6 files, 62 tests passed; the embedded-view regression failed + before implementation and passed afterward. +- `corepack pnpm run typecheck` — passed. +- Scoped ESLint across all changed source and test files — passed with no output. +- `corepack pnpm run build:vite` — Renderer, Main, Preload, and utility builds passed; + only existing Browserslist, mixed-import, and chunk-size warnings remained. +- `node ./node_modules/@playwright/test/cli.js test tests/e2e/project-plugins.spec.ts tests/e2e/plugin-marketplace.spec.ts` — 4/4 passed, including assertions that the Project Configuration page remains mounted behind the Plugin drawer. +- `corepack pnpm test` — 221 regular files (1861 passed, 3 skipped) and the pressure suite (1 passed). +- `corepack pnpm run lint:check` — passed with 0 errors and 5 pre-existing warnings. +- `git diff --check` — passed. + +## Follow-ups + +- Integration should reconcile the navigation wording in canonical project memory + and review overlap with planning task `20260906-remove-project-gate-7c31` before + merging. No product-semantic conflict was found in the current peer-task audit. + +## Promotion Candidates + +- Target: `.project-docs/30-worklog/current-state.md`, + `.project-docs/20-architecture/system-overview.md`, and + `.project-docs/40-domain/business-rules.md`. +- Proposal: record the Agent/Skill Project Configuration page as the visible + owner of the single unified Plugin workspace. Its `插件` card opens a same-page + wide drawer; `/project-config/plugins` is the canonical deep link for that + state, and old public URLs remain deterministic compatibility redirects. This + rehomes the reviewed unified workspace and does not restore the superseded + duplicate Plugin Services implementation. +- Evidence: explicit user direction in this task, focused unit coverage, 4/4 + Electron E2E, full unit/typecheck/lint/build verification, and the task-owned + proposal `20260906-move-plugins-project-settings-7c91a4e2__project-settings-plugin-navigation.md`. +- Future impact: new Plugin navigation must originate in Project Configuration, + while Plugin lifecycle boundaries, Web Search exclusion, and Main-owned + authority remain unchanged. +- Semantic conflict: this supersedes canonical statements that the Code sidebar + owns the only Plugin entry and `/plugins` is canonical; it does not conflict + with ADR-008 or the Plugin lifecycle boundaries. +- Human confirmation: already supplied and clarified by the user: Plugins belong + on the same project page used to configure Agents and Skills, not on a separate + page. No additional product-direction approval is required during integration. diff --git a/README.md b/README.md index 75e6478..edd61ab 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工 - AI 绘画生成:服务端从已确认规格编译专业图片或视频指令并返回不可变 Quote;客户端只展示媒介、画幅、数量、格式、时长、警告和设计点,不展示或改写供应商 Prompt、模型、价格原子或存储地址。确认时只提交 Quote 身份。图片可使用当前 Workspace 的上传素材或生成作品,视频可绑定已审核首帧;任务与资产始终属于 Workspace。 - AI 绘画健壮性:Renderer 为每次命令生成稳定 operation id,网络结果未知时保留原命令供原样重试,不把未知写入当失败或创建第二次生成。Main 负责 Token 刷新、Agent Gateway REST 提交和有界 Run 查询,并将可恢复事件流投影为 Host API SSE;断线后按事件游标续接并重新读取权威 Workspace。Canvas 只使用 Works Square 云端 V2 契约,没有本地语义适配器或降级路径,上游不可用时明确报错。 - AI 绘画项目栏只展示 Workspace,不再在项目下创建独立设计会话。删除时必须完整输入项目名称;删除后项目、Living Form、任务、参考图和生成作品会从账户中隐藏且无法访问,不影响用户已另存到磁盘的副本。删除当前项目后自动打开最近更新的剩余项目,删除最后一个项目后进入空状态。 -- 插件工作台:Code 侧栏只有一个“插件”入口,`/plugins` 在同一列表中投影官方目录、账号 Library、本机 Device Packages 与当前项目状态;旧 Marketplace、My Plugins、Project Plugins URL 只做确定性筛选重定向。来源身份保持 `official:`、`local:`、`retained:` 分离;“免费获取”、官方设备交付、项目启用和伙伴分配仍分别写入既有 authority,不会自动推进下一步。任一来源失败不会清空其他来源;本机 Skill/Pi 插件仍只能通过对话安装,页面只管理已安装包并明确其本机全局生效范围。 +- 项目插件设置:Code 侧栏不再单列“插件”;配置 Agent、Skill、模型与知识的项目配置页内提供“插件”卡片,并在同页宽抽屉中投影官方目录、账号 Library、本机 Device Packages 与当前项目状态。深链使用 `/project-config/plugins` 打开该项目页抽屉;旧 `/plugins`、Marketplace、My Plugins、Project Plugins URL 只做保留查询条件的确定性重定向。来源身份保持 `official:`、`local:`、`retained:` 分离;“免费获取”、官方设备交付、项目启用和伙伴分配仍分别写入既有 authority,不会自动推进下一步。任一来源失败不会清空其他来源;本机 Skill/Pi 插件仍只能通过对话安装,页面只管理已安装包并明确其本机全局生效范围。 - 官方插件运行架构:Renderer 只调用 Main-owned Marketplace facade;Main 负责账号、请求 deadline、签名/摘要校验、不可变 Release、current selection 与原子回滚。Pi parent worker 使用冻结的 effective snapshot,将每个有效 Skill 与已验证 Package Store root 成对传给 resource loader、Extension Host 和 CLI;普通 `skill_only` 下载包不依赖运行时 Policy,也不得携带可执行脚本。Project Scaffold 的 `.mjs` 是代码所有的 bundled 例外,只能从签名客户端固定资源根通过应用自带 Node 执行,不能扩展为 Marketplace 下载 artifact 的脚本权限。正式激活仍等待官方 Ed25519 公钥(production key activation HOLD);生产私钥只能来自部署 secret,测试使用注入的临时密钥。 - 原生 Web Search 是冻结 selected model/provider/credential 的 parent-only 模型工具;只有 Works `model_capabilities` 明确声明支持时才出现,使用当前模型的正常计费链,不依赖 Marketplace 获取、Release、Admission、Hosted Provider 或 `agent_browser` 回退。 - 本机 Device Packages 只由 Conversation 工具发起安装,支持 npm、Git、绝对本地 Plugin 目录和 loose `SKILL.md`。Main 在预览和独立确认后提交不可变本机 generation,禁用生命周期脚本;确认界面明确提示 Pi extension 以桌面当前用户权限执行。新建/空闲 parent 会同时加载所有显式安装且当前启用的 Skill,以及必需 Makelore 扩展之外的全部 Pi extension,active parent 在 turn settled 后切换,child 不继承这些资源,也不会扫描 ambient 项目或用户目录;Renderer 不提供安装源选择器。 diff --git a/scripts/lib/pi-product-artifact.mjs b/scripts/lib/pi-product-artifact.mjs index 021e2fd..4c2977e 100644 --- a/scripts/lib/pi-product-artifact.mjs +++ b/scripts/lib/pi-product-artifact.mjs @@ -69,7 +69,7 @@ const MARKETPLACE_ARTIFACT_MARKERS = Object.freeze({ '/api/coding/plugin-marketplace/library', '免费获取', '获取、项目启用与伙伴分配彼此独立', - '/plugins', + '/project-config/plugins', ]), }); const FORBIDDEN_PROVIDER_AUTHORITY_MARKERS = Object.freeze([ diff --git a/src/App.tsx b/src/App.tsx index f1cd4a1..6c520ec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,14 +26,17 @@ import { subscribeHostEvent } from '@/lib/host-events'; import { reportDesktopActivity, type DesktopActivityModule } from '@/lib/host-api'; import { installRendererPerformanceDiagnostics } from '@/lib/performance-diagnostics'; import { resolveSupportedLanguage } from '../shared/language'; -import { LegacyPluginRedirect } from './pages/Plugins/legacy-plugin-redirect'; +import { + LegacyPluginRedirect, + PluginWorkspaceRedirect, + PROJECT_SETTINGS_PLUGINS_PATH, +} from './pages/Plugins/legacy-plugin-redirect'; const Models = lazy(() => import('./pages/Models').then(({ Models: component }) => ({ default: component }))); const Chat = lazy(() => import('./pages/Chat').then(({ Chat: component }) => ({ default: component }))); const Home = lazy(() => import('./pages/Home').then(({ Home: component }) => ({ default: component }))); const PreviewScene = lazy(() => import('./pages/Makelore').then(({ PreviewScene: component }) => ({ default: component }))); const ProjectConfiguration = lazy(() => import('./pages/ProjectConfiguration').then(({ ProjectConfiguration: component }) => ({ default: component }))); -const Plugins = lazy(() => import('./pages/Plugins').then(({ Plugins: component }) => ({ default: component }))); const Workbench = lazy(() => import('./pages/Workbench').then(({ Workbench: component }) => ({ default: component }))); const ImageCanvas = lazy(() => import('./pages/ImageCanvas').then(({ ImageCanvas: component }) => ({ default: component }))); const ImagePromptMuseum = lazy(() => import('./pages/ImagePromptMuseum').then(({ ImagePromptMuseum: component }) => ({ default: component }))); @@ -442,7 +445,8 @@ function App() { )} > } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 06c2037..73a2ab6 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -38,6 +38,7 @@ export function MainLayout() { const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/'); const isChatWorkspace = location.pathname === '/chat'; const isInitializationSafeRoute = location.pathname === '/project-config' + || location.pathname.startsWith('/project-config/') || location.pathname === '/plugins' || !isProgrammingModule; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index f5e9b8b..f868717 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -12,7 +12,6 @@ import { FolderKanban, LogOut, Plus, - Puzzle, Settings as SettingsIcon, UserCircle2, } from 'lucide-react'; @@ -584,23 +583,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi {!sidebarCollapsed && '新建项目'} -
- -
+
+ ) :
+
+ {props.onBackToProjectSettings ? ( + + ) : null} +
+

项目配置

+

插件

+

+ MakeLore 运营发布官方插件;本机 Skill 和 Pi extension 仅通过对话安装。获取、项目启用与伙伴分配彼此独立,这里集中展示账号、设备、当前项目与伙伴状态。 +

+
-
+ } -
+