diff --git a/.project-docs/00-brief/success-criteria.md b/.project-docs/00-brief/success-criteria.md index 2cf22e9..e076e90 100644 --- a/.project-docs/00-brief/success-criteria.md +++ b/.project-docs/00-brief/success-criteria.md @@ -8,6 +8,8 @@ - 可发布交互式 AI 应用的一键提交必须由 Electron Main 对安全源码快照执行固定 npm 11.6.2 的 `npm ci --ignore-scripts`,并调用项目 `package-lock.json` 锁定的 Vite 生成静态产物。 - 官方 bundled Project Scaffold Skill 可生成固定起步文件并只读说明发布要求、禁止项和证据缺口,但不得安装依赖、构建、上传、提审或把静态检查表述为平台批准;其 `.mjs` 只来自签名客户端固定资源,Marketplace 下载 artifact 仍必须拒绝脚本。 +- 代码所有的官方项目插件 `makelore.data-service`、`makelore.game-resource` 与 `makelore.project-scaffold` 在满足各自既有账号获取或随应用提供条件并由当前项目启用后,必须自动进入每个 parent Agent,且客户端不得要求或展示伙伴分配;child Agent 仍为空,其他 Marketplace 和本机包的既有生命周期不变。 +- Game Resource 的一次显式确认必须对应一次 Main-owned Provider 提交;Main 内部轮询并把全部终态输出自动保存到调用时冻结项目的 `assets/generated/game-resource//`。响应丢失、保存失败或应用重启只能恢复本地下载/保存,不得再次生成或扣费;项目写租约只在终态落盘期间持有,Agent 不再拥有状态轮询、保存路径或第二次确认。 - Electron 双视口预检必须检查与最终 `built_archive` 相同的内存文件字节;预检失败不得上传,预检成功不得被表述为可信审核凭据。 - 上传协议必须同时携带源码归档、构建归档和严格版本化 artifact contract;服务端独立重算摘要、校验合同并固化不可变 Release。 - Renderer 不得获得发布凭据、归档、临时目录、构建 origin 或任意本地路径;旧客户端和旧 sandbox/browser 任务必须提示升级后重新构建提交。 @@ -35,4 +37,4 @@ ## Last Reviewed -2026-09-04 +2026-09-07 diff --git a/.project-docs/10-decisions/adr-008-interactive-ai-app-scaffold.md b/.project-docs/10-decisions/adr-008-interactive-ai-app-scaffold.md index 12c9eec..135f84c 100644 --- a/.project-docs/10-decisions/adr-008-interactive-ai-app-scaffold.md +++ b/.project-docs/10-decisions/adr-008-interactive-ai-app-scaffold.md @@ -1,9 +1,9 @@ -# ADR-008: 交互式 AI 应用使用显式 Scaffold Skill +# ADR-008: 交互式 AI 应用 Scaffold 与官方项目插件采用项目级激活 - Status: Accepted / implemented, amended 2026-09-07 - Date: 2026-09-04 - Last amended: 2026-09-07 -- Applies to: Project creation, `ProjectType`, official bundled Marketplace Plugins, scaffold Skill, release readiness +- Applies to: Project creation, `ProjectType`, code-owned official project Plugins, scaffold Skill, release readiness ## Context @@ -13,13 +13,17 @@ 后续面向青少年用户的验证表明,要求用户在创建时理解产品类型、模板、UUID 与初始化状态仍是多余门槛。用户进一步确认普通创建只应选择目录,其余身份与安全默认由客户端和 Main 自动处理;Scaffold Skill 保持可选能力,而不是创建或进入聊天的前置步骤。 +后续实际使用又证明,把伙伴分配作为代码所有官方项目插件的第二个项目级开关会产生重复且相互矛盾的状态。用户进一步确认 Data Service、游戏资源生成、项目初始化与发布检查都应在项目启用后直接对项目生效,伙伴分配只保留给确实采用该范围的其他插件。 + ## Decision - 普通新建项目只要求选择目录,不展示产品类型、模板、原始 UUID、绑定或独立副本选项。Renderer 写入内部默认 `interactive_ai_app`,Main 生成规范 UUID;`ProjectType` 创建后仍不可变。既有 `custom` 项目和底层兼容入口继续支持,但 `custom` 不再作为普通新建选项。 - `mini_game` 与 `mini_program` 只作为历史读取和脚手架兼容别名,在内存中归一为 `interactive_ai_app`。读取或运行脚手架不会改写原配置;后续普通配置变更可以保存规范值。缺少类型字段的旧项目仍归一为 `custom`。 - 项目创建只生成 `.makelore/project.json` 和 `knowledge/`,不生成业务源码、依赖、锁文件或发布模板。 - Main 为新项目自动生成 UUID,并为“其他 metadata 有效、仅缺 `projectId`”的旧配置串行补齐并持久化一次。原始 UUID、bind/independent-copy 选择不进入普通 UI;其他 metadata 缺失或无效仍是配置错误。旧 `initialized` 布尔只保留为 schema 兼容字段,不得阻断有效项目导航、聊天或首个 Agent 创建。 -- 代码内置的官方 Marketplace Plugin `makelore.project-scaffold` 提供可选显式 Skill `makelore-project-scaffold`。它不是项目创建或聊天前置条件;其 Account Library 获取、项目启用和 Agent 分配语义保持独立,固定版本资源随 MakeLore 客户端交付且不经过 Package Store 下载。脚本生成六个交互式 AI 应用起步文件;写入前预检全部目标,不覆盖已有路径,受控失败时只回滚本次创建的文件和目录。 +- 代码所有的官方项目插件 `makelore.data-service`、`makelore.game-resource` 与 `makelore.project-scaffold` 采用统一的项目级激活规则。Data Service 随应用提供;Game Resource 与 Project Scaffold 保留 Account Library 获取;三者都保留项目启用语义,但不再要求或展示 Agent 分配。项目启用后,Main-owned effective resolver 自动把完整 Skill/tool 集合提供给该项目的每个父 Agent;child Agent 仍为空。需要伙伴分配的其他 Marketplace 插件继续使用原规则。 +- `makelore.game-resource` 的生成操作采用一次确认、一次提交、自动交付。Main 冻结调用时的项目身份与路径,内部轮询已接受的 Provider execution,把全部终态输出保存到该项目固定目录,并只在落盘期间获取共享项目写租约。持久交付 receipt 将 Provider/billing 与本地下载/保存分开;恢复本地交付不得重新提交或计费。Agent 不再暴露 status/save 工具、路径选择或第二次保存确认。 +- `makelore.project-scaffold` 提供可选的显式 Skill `makelore-project-scaffold`,不是项目创建、进入聊天或创建首个 Agent 的前置条件。固定版本资源随 MakeLore 客户端交付,不经过 Package Store 下载。其脚本生成六个交互式 AI 应用起步文件;写入前预检全部目标,不覆盖已有路径,受控失败时只回滚本次创建的文件和目录。 - Scaffold Skill 不安装依赖、不访问网络、不执行构建、不上传、不提交审核,也不提供 `--force`、类型覆盖或模板迁移状态。Main 继续独占固定 npm/Vite 构建、同字节预检、打包和上传;Works Square 继续独占服务端校验、不可变 Release 与运营审核。 - 官方 Plugin 的 `.mjs` 仅因它位于客户端固定、代码所有的 bundled resource root 中而可执行。应用通过不可覆盖的 `MAKELORE_NODE_EXECUTABLE` 向父 Pi worker/Agent Server 提供自身 Node;不得回退系统 Node。该例外不适用于 Marketplace 下载 artifact:P0 下载包仍只接受文本/图片 Skill 资源并拒绝 `.mjs`。第三方 Device Package 的可执行代码继续走自身的披露与确认边界。 - Skill 的发布检查只是只读准备度指导:区分确定阻断、源码包排除影响、运行时/平台待确认项和静态已满足项,并引用项目相对证据。它不能宣称构建、上传、审核或生产发布成功。 @@ -30,6 +34,8 @@ - 普通用户可从目录选择直接进入 `/chat`;没有 Agent 时显示非阻塞空状态并提供创建首个 Agent 的入口。Project Configuration 仍处理真正无效的 metadata,但不再承担初始化仪式。 - 旧项目无需批量迁移即可继续打开、生成脚手架和发布;普通新写入使用内部默认 `interactive_ai_app`,既有 `custom` 与历史类型保持兼容。 - 发布规则在 Skill 中可被 Agent 解释和预检,但权威执行仍只有 Main 与 Works Square,避免形成第二套发布实现。 +- 这三个代码所有官方身份的既有 Agent assignment 数据不再参与有效资源计算,可以原样保留;账号移除(适用时)或项目禁用仍会阻止新父 Agent 获得资源,运行中的 generation 仍按既有冻结边界切换。 +- Game Resource 的 Provider 作业可以继续由 Main 在后台收敛,而项目文件写入只占用短租约;项目切换不改变已冻结的交付目标,应用或响应中断也不把本地交付失败变成第二次付费生成。 - 对外分发仍需不可变 SemVer/Git 版本、服务端 bundled Release 元数据、已安装 Windows、签名 macOS 与 native Linux 运行证据;工作区测试不能替代这些发布门禁。 ## Supersedes @@ -45,6 +51,17 @@ - Source task: `20260904-project-scaffold-implementation-7e4c2a91` - Marketplace source task: `20260904-marketplace-scaffold-client-6c4e8a21` - Marketplace integration task: `20260904-project-scaffold-integration-client-4f2a8c71` +- Project-wide activation source commit: `300ac89a81409440aac84ff45b1d9ca2fa186629` +- Project-wide activation source task: `20260905-project-plugin-scope-7c4e9a21` +- Project-wide activation integration task: `20260905-project-plugin-scope-integration-4d8a2c71` +- Official project-wide activation source commit: `718783f6837e29f56c9add633596249c03e5701f` +- Official project-wide activation product commit: `e0de7aa28c1d6e97454f0e4073ae9153e746bb4b` +- Official project-wide activation source task: `20260905-official-plugin-project-scope-6e4a9c21` +- Official project-wide activation integration task: `20260905-official-plugin-project-scope-integration-8b3d6f42` +- Automatic Game Resource delivery source commit: `6113a2453299141eab8420a56e93675712dd607b` +- Automatic Game Resource delivery integration product commit: `4df4bc96245c01cd95e35ddf1b2b03d0e0d231c5` +- Automatic Game Resource delivery source task: `20260906-game-resource-auto-delivery-7a4e2c91` +- Automatic Game Resource delivery integration task: `20260906-game-resource-auto-delivery-integration-8c4e1a72` - Proposal: `10-decisions/proposals/20260904-project-scaffold-implementation-7e4c2a91__interactive-ai-app-type.md` - Simplified-creation source commit: `af13aca` - Simplified-creation integration merge: `39d7b7e` diff --git a/.project-docs/10-decisions/decision-index.md b/.project-docs/10-decisions/decision-index.md index 590a266..852526e 100644 --- a/.project-docs/10-decisions/decision-index.md +++ b/.project-docs/10-decisions/decision-index.md @@ -4,7 +4,7 @@ | ID | Decision | Status | Date | Applies To | Detail | |---|---|---|---|---|---| -| ADR-008 | 交互式 AI 应用使用单一内部默认类型;目录式创建、Main-owned UUID、可选 bundled Scaffold Skill 与发布权威分离 | Accepted / implemented, amended 2026-09-07 | 2026-09-04 | Project creation、`ProjectType`/identity、Official Plugins、Marketplace delivery、release readiness | `adr-008-interactive-ai-app-scaffold.md` | +| ADR-008 | 交互式 AI 应用使用单一内部默认类型;目录式创建、Main-owned UUID、项目级官方插件、可选 Scaffold Skill 与发布权威分离 | Accepted / implemented, amended 2026-09-07 | 2026-09-04 | Project creation、`ProjectType`/identity、Official Plugins、Marketplace delivery、project-wide activation、Game Resource auto-delivery、release readiness | `adr-008-interactive-ai-app-scaffold.md` | | ADR-007 | AI Design 采用单一 Current Specification、Living Form 与不可变 Quote 的 V2 权威;active plan 与 reference alias 是其公共投影 | Accepted / implemented, amended 2026-09-07 | 2026-08-30 | AI Design Renderer、Electron Main、Works Square V2 API | `adr-007-ai-design-living-form-v2.md` | | 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` | diff --git a/.project-docs/20-architecture/data-flow.md b/.project-docs/20-architecture/data-flow.md index 864bb68..1952244 100644 --- a/.project-docs/20-architecture/data-flow.md +++ b/.project-docs/20-architecture/data-flow.md @@ -6,17 +6,18 @@ |---|---|---|---| | Marketplace catalog and Account Library | Signed-in Renderer intent | bounded Host API → Main Marketplace client/cache → Works Square catalog, Library, resolve, Admission, and download APIs | Main binds requests to the current Account and response generation. Free acquisition changes only Account Library; it does not install, enable a project, assign an Agent Skill, or authorize runtime execution. | | Marketplace device installation | Account Library entry and explicit channel | Main resolve/Admission/download → descriptor/signature/archive/client compatibility verification → immutable Package Store index switch | Stable and explicit Beta remain distinct even when they resolve to the same immutable Release. Failed install/update retains the previous current release; explicit uninstall waits only for protecting worker/account references and completes after the final worker exits. | -| Official bundled Plugin acquisition | Account Library entry for Game Resource or Project Scaffold | server stable bundled Release/Admission + exact MakeLore resource package | Free acquisition changes Library only; project enablement and Agent assignment remain separate. No device download, update, Beta, artifact signature, or device-uninstall action exists for either code-owned identity. Game Resource is hosted; Project Scaffold is `skill_only` and may execute only its bundled `.mjs`. Historical Hosted Web Search entries are ignored. | +| Code-owned official Plugin activation | System-included Data Service or Account Library entry for Game Resource / Project Scaffold | existing delivery/acquisition + project enablement + exact MakeLore resource package | Delivery/acquisition and project enablement remain separate. Once enabled, all three identities enter every parent Agent without assignment; child Agents remain empty. No device download, update, Beta, artifact signature, or device-uninstall action exists for these code-owned identities. Project Scaffold may execute only its bundled `.mjs`; historical Hosted Web Search entries are ignored. | | Selected-model Web Search | Parent Pi turn with an explicitly supported selected model | `makelore_web_search` core tool → frozen model/provider/credential request with provider-native forced search → ordinary model response/usage | No Marketplace Release, Account Library, Admission, Hosted Web Search client, Plugin Charge, or `agent_browser` fallback participates. Unsupported selected models expose no tool; child workers receive none. | | Conversation-driven Device Package install | Agent tool inspects npm/Git/absolute local Plugin/loose Skill source | Main preview → distinct later user confirmation → immutable device-package generation → new/idle parent worker resources | Renderer has no install picker. Lifecycle scripts never run. Pi extensions and non-empty Skill `scripts/` are disclosed as desktop-user executable code before confirmation. Active workers retain their frozen generation until the turn settles; child workers remain empty. | -| 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. | +| Effective Plugin worker snapshot | Installed trusted package or code-owned official definition + project selection + applicable 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. Data Service、Game Resource 与 Project Scaffold 直接按项目启用状态取得资源;Agent assignment 只对采用该范围的其他 Plugin 保持权威。Disable、账号/项目切换、logout、Renderer crash、Main shutdown 或 worker generation 变化会使后续动作失效,但不改写持久化的未知 assignment;child worker 不接收 Plugin 投影。 | | Plugin workspace navigation | Project Configuration `插件` ResourceCard or compatibility URL | `/project-config/plugins` → Project Configuration remains mounted → same-page wide Plugin sheet → unified Plugin stores/Main routes | Code sidebar has no standalone Plugin entry. `/plugins` and old Plugin URLs only preserve query/filter intent while redirecting; embedding does not merge acquisition, install, project enablement, assignment, runtime authorization, or billing lifecycles. | -| 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. | +| Hosted Game Resource operation and delivery | Eligible parent `makelore.game-resource` generate call plus one explicit confirmation | frozen Plugin adapter → Main delivery coordinator → one `GameResourceClient` submission → internal status polling → all terminal downloads → `assets/generated/game-resource//` in the frozen original project | Server policy owns pricing、payer、Admission 与 Provider receipt state;Main owns the durable local delivery receipt and filesystem. `submission_unknown` 不会作为新请求重放。重启或重试只恢复下载/保存,共享项目写租约仅在终态落盘期间持有;Agent 只收到一张进度/结果卡片,不暴露 status/save 工具,也不要求第二次确认。 | | 桌面认证生命周期 | 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 | +| Account Token Points V2 | 已登录账号菜单、窗口 focus 或重新可见 | Renderer → Main `/api/works/billing/points` → Works Square `/api/billing/points` → safe balance projection | 可管理会员的账号获得规范字符串点数与套餐/周期元数据;非管理者的精确字段全部置空,只投影 `shared_available`、共享/青少年类型和升级责任。旧 rolling-window usage API 与 stale event 不再参与。 | | 用户模块入口策略 | 会话恢复 / 登录 / 刷新 | Electron Main → Works `/api/auth/me` → 三布尔安全投影 → Renderer auth store → 卡片/路由/provider gate | 缺失对象或字段默认 `true`;`design` 映射 `painting`;额外旧字段被忽略;终止性 `401` 清理 Main/Renderer 会话;全局 `/settings` 不受 Code gate | | 项目创建 | 新建项目对话框中的目录选择 | Renderer 内部默认 `interactive_ai_app` → Host API → Main 生成 UUID 并原子初始化 | 普通用户不选择类型、模板或项目身份;只生成 `.makelore/project.json` 与 `knowledge/`,随后直接进入 `/chat`。既有 `custom`/历史类型和底层兼容入口仍保留 | | 旧项目身份补齐 | 有效 `.makelore/project.json` 仅缺 `projectId` | Main serialized config read/repair → canonical UUID persisted once | 并发读取共享同一次修复;原始 UUID、bind/independent-copy 控件不进入普通 UI。其他缺失或无效 metadata 仍是配置错误;`initialized` 不阻断有效项目 | -| 显式项目脚手架 | 用户为已获取、项目启用并分配给 Agent 的 Project Scaffold Plugin 明确要求初始化 | Pi `bash` + 项目 write lease → `MAKELORE_NODE_EXECUTABLE` → bundled `makelore-project-scaffold` 脚本 | 固定六文件目标;全量预检、不覆盖、受控失败只回滚本次路径;不安装依赖、不联网、不构建、不上传、不提审;下载 artifact 不参与 | +| 显式项目脚手架 | 用户在已获取且项目启用的 Project Scaffold Plugin 下明确要求初始化 | Main-owned effective resolver 自动向该项目父 Agent 提供 Skill → Pi `bash` + 项目 write lease → `MAKELORE_NODE_EXECUTABLE` → bundled `makelore-project-scaffold` 脚本 | 不要求伙伴分配,也不是项目创建或聊天前置条件;child 仍为空。固定六文件目标;全量预检、不覆盖、受控失败只回滚本次路径;不安装依赖、不联网、不构建、不上传、不提审;下载 artifact 不参与。 | | 一键提交 | `ProjectPublishAction` | Renderer capability → Host API → Main 本地 npm/Vite build → built snapshot preflight → source+built+contract 上传 | 只对规范交互式 AI 应用(含读取时归一的历史别名)开放;首次 create 通过单一 multipart 合同原子提交文字资料与必选 PNG/JPEG/WebP 封面,创建失败或冲突不上传版本;已有 draft/published 只提交版本并沿用云端资料/封面,不做无条件 metadata PATCH | | 构建产物预检 | Main-owned built snapshot | 一次性 loopback origin → fresh Electron WebContents/CDP(桌面、移动) | 检查错误、白屏和外域;不调用 Playwright,检查与上传归档相同字节,但不产生可信 receipt | | 提交绑定 | 云端成功上传响应 | Main → submission binding v2 | 只持久化成功的 app/version/review/hash;落盘失败返回固定告警但不反转提交 | @@ -29,6 +30,7 @@ | AI 编程上下文压缩 | Renderer compact action / Pi compaction events | correlated compact RPC → target projector → Snapshot/Patch timeline | Pi `0.84.2` 的手动 compact 不发 `agent_settled`;RPC success 或权威 compaction failure 终结 compact 并 exactly-once 释放对应 ownership,不提前结算普通 prompt | | AI 编程 Provider 错误 | Pi logical-thread Provider 请求 | selected thread-local credential store → Main AI proxy → Works 模型上游 → projector safe error | 确定性的 Works user-context 缺失使缓存 gateway credential 过期、返回非重试认证失败且不重放请求;Renderer 只看到固定 Provider-auth 提示,不能把它解释为 Pi 崩溃 | | AI 编程子 Agent | parent logical thread `subagent.v1` tool call | explicit extension → authenticated Main bridge → scheduler → independent ephemeral Pi child process | child 并发最多 4、单次最多 8、禁止递归;child 使用 FIFO 进程预算 8,shared parent 逻辑线程不各占一个 process lease;coding child 与 parent 共用项目 write lease,父 abort/crash/generation 失效必须清理 child 与 permit | +| 共享开发浏览器 | 用户面板打开或 parent Agent `agent_browser open` | 当前项目身份 → Main-owned browser route/module → sandboxed `WebContentsView` + bounded CDP → 同一 Renderer 右侧 viewport/Console/Network | Agent 发起打开后先通知 Renderer 展示并最多等待 5 秒取得当前 generation 的可见 bounds;项目 id 仅允许具备 Renderer capability 的请求使用。非 Web、文件注入、跨目标及宿主级 CDP 被拒绝;诊断 owner 释放到零后停止采集。 | | 客户端更新检查 | 设置页 | Renderer update store → IPC → Main AppUpdater → 目标 feed | Main 记录并重抛原始错误;Renderer 只显示去重、脱敏的单条提示,稳定源缺包不伪装为最新版 | | Design Workspace load | Canvas 中央区与右侧 Works rail | Renderer Host API → Main-owned Works Square V2 adapter → current Workspace/Direction/Living Form projection | 一个 Workspace 公开一个 current Direction 与 persistent Agent Session;右 rail 只选择/创建/删除 Workspace,conversation timeline 是交互历史,不是独立 authority object | | Design input and reconciliation | Chat/direct edit/decision/proposal/lock/Asset binding/restore | Renderer draft → Main → `design.input.apply` → canonical Direction projection | 全部进入同一 reducer;accepted 前 draft 保持本地,revision conflict 刷新权威投影,unknown result 复用原 command/operation identity | diff --git a/.project-docs/20-architecture/module-map.md b/.project-docs/20-architecture/module-map.md index ea12558..f46db0e 100644 --- a/.project-docs/20-architecture/module-map.md +++ b/.project-docs/20-architecture/module-map.md @@ -6,20 +6,21 @@ |---|---|---| | `electron/coding-plugins/manifest.ts`, `release-descriptor.ts`, `signature-verifier.ts`, and `trusted-keys.ts` | Closed Plugin package/descriptor parsing, fixed code-owned resource roots, compatibility checks, canonical archive validation, and Ed25519 trust | Data Service plus exact Game Resource and Project Scaffold roots are code-owned. Other schema-2 packages still pass descriptor/signature/archive validation; downloadable P0 Skill assets remain text/image-only. Hosted Web Search is not a current Plugin root. | | `electron/coding-plugins/account-plugin-cache.ts`, `marketplace-client.ts`, and `package-store.ts` | Main-owned Account Library cache, authenticated server client, and atomic immutable device installation store | Account acquisition and device installation are separate. Package bytes switch atomically only after descriptor, signature, archive, client-range, and provenance checks; failed updates preserve the prior current release. | -| `electron/coding-plugins/effective-resolver.ts`, `registry.ts`, `project-service.ts`, and `electron/coding-runtime/pi/**` | Effective official Plugin projection, selected-model tools, Device Package resources, and frozen parent logical-thread runtime snapshot | Project enablement and Agent Skill assignment are preserved independently. Trusted Marketplace artifacts, exact acquired official bundled definitions, and immutable local Device Package generations enter through distinct authorities. Child workers remain empty; active threads retain frozen resources until settlement/disposal. | +| `shared/coding-plugins.ts`, `electron/coding-plugins/effective-resolver.ts`, `registry.ts`, `project-service.ts`, and `electron/coding-runtime/pi/**` | Effective official Plugin projection, selected-model tools, Device Package resources, and frozen parent logical-thread runtime snapshot | The shared project-wide predicate covers `makelore.data-service`, `makelore.game-resource`, and `makelore.project-scaffold`: after their existing delivery/acquisition requirement and project enablement, all parent Agents receive their Skills/tools without assignment. Trusted Marketplace artifacts that require assignment and immutable local Device Package generations retain their distinct authorities. Child workers remain empty; active threads retain frozen resources until settlement/disposal. | | `electron/coding-runtime/pi/model-tools/**` and `shared/model-tools.ts` | Closed selected-model tool registry and provider-specific Web Search request shaping | The frozen selected model capability controls whether `makelore_web_search` exists. The tool uses that model/provider/credential and normal model billing; no Hosted Plugin adapter, Admission, Plugin Charge, or browser fallback exists. | | `electron/coding-packages/**`, `electron/api/routes/device-packages.ts`, `shared/device-packages.ts`, and `src/stores/device-packages.ts` | Main-owned conversation install preview/confirmation/commit, immutable local package generations, safe Renderer projection, and parent-worker refresh | Sources are npm, Git, absolute local Plugin directories, or loose `SKILL.md`. Lifecycle scripts are disabled; executable extensions and non-empty Skill `scripts/` run with desktop-user authority after disclosure and explicit confirmation. Every generation projects all explicitly installed and currently enabled Skills/extensions; Device Packages never join Account Library, Marketplace Package Store, Release, Channel, or Admission state. | -| `electron/coding-plugins/adapters/game-resource.ts` and `electron/services/game-resource-client.ts` | Provider-neutral `makelore.game-resource` hosted tool adapter and Main-owned Works Square transport | Tools materialize only from an eligible frozen `platform_hosted` parent snapshot. Metered mutations require explicit confirmation and stable logical operation identity; Renderer/Pi never receive Provider URLs, credentials, balances, raw responses, or Provider job IDs. | +| `electron/coding-plugins/adapters/game-resource.ts`, `electron/services/game-resource-client.ts`, and `electron/services/game-resource-delivery.ts` | Provider-neutral `makelore.game-resource` adapter, Main-owned Works Square transport, and durable local delivery coordinator | An eligible frozen parent submits one confirmed generation. Main polls internally, persists delivery state under userData, downloads every terminal output, and writes it to the frozen original project. A delivery retry resumes download/save only; Renderer/Pi never receive Provider URLs, credentials, balances, raw responses, Provider job IDs, or filesystem authority. | | `electron/api/routes/plugin-marketplace.ts`, `src/stores/{plugin-marketplace,device-packages,coding-plugins}.ts`, `src/pages/Plugins/`, and `src/pages/ProjectConfiguration/index.tsx` | Existing Main/store authorities plus the unified Renderer projection embedded in Project Configuration | `/project-config/plugins` is the canonical same-page sheet state. `/plugins` and legacy Plugin routes only replace-redirect while preserving deterministic filters. `official:`, `local:`, and `retained:` identities stay separate, source failures are isolated, and no Account token, filesystem path, Admission, package bytes, signed URL, or visible install-source picker enters Renderer. | | `src/components/works/ProjectPublishAction.tsx` | 可发布项目的一键提交、云构建轮询与用户可理解状态 | 只通过 Renderer API 提交非敏感元数据;绑定告警不终止轮询 | | `shared/project-config.ts`, `electron/coding-projects/{project-config,project-service}.ts`, and `src/components/layout/Sidebar.tsx` | 规范 `ProjectType`、Main-owned UUID 与目录式最小项目创建 | 普通新建只要求目录,内部默认 `interactive_ai_app`,Main 自动生成 UUID;有效旧配置仅缺 `projectId` 时串行修复。既有 `custom` 和历史类型仍兼容,`initialized` 不再是导航 gate | | `resources/coding-plugins/project-scaffold/` and `tests/project-scaffold/` | 官方 bundled Scaffold Plugin、固定六文件模板、确定性 Node 脚本、发布要求参考与回归测试 | 通过既有 Pi `bash` write lease 和 `MAKELORE_NODE_EXECUTABLE` 显式运行;脚本信任来自固定客户端资源,而非下载 artifact;不覆盖、不安装依赖、不联网,也不复制 build/upload/review 实现 | | `src/lib/works-square.ts` | Renderer 侧 Works Square Host API 契约与安全错误映射 | 不接触 Token、ZIP、本地绝对路径或自动部署状态 | +| `electron/api/routes/works.ts`, `src/lib/works-square-token-points.ts`, and `src/components/layout/Sidebar.tsx` | Token Points V2 的 Main-owned 上游代理、安全 DTO 投影、格式化与账号菜单状态 | 管理者可见规范字符串点数与周期;非管理者只见粗粒度可用性。旧 rolling-window usage contract 和 Renderer stale event 已删除。 | | `electron/api/routes/works.ts` | Works Host API、Renderer capability 门禁、上游安全投影 | 发布凭据、打包、上传与本地绑定均在 Main 内完成 | | `electron/services/project-release-builder.ts` | Main-owned 安全快照、本地 npm/Vite 构建、source+built 双归档与 v1 contract | 固定 npm 11.6.2;项目 Vite 由 lockfile 决定;Vite config/plugins 以桌面用户权限执行 | | `electron/services/publish-runtime.ts` | 安装包内 npm 闭包定位与 Electron Node 执行 | 不回退全局 npm/PATH;缺失或版本不符 fail closed | | `electron/services/static-release-server.ts` | 用内存 built snapshot 建立一次性 loopback origin | 预检和最终上传归档必须来自相同文件字节;总是清理临时服务 | -| `electron/agent-browser/module.ts` | built artifact 的桌面/移动临时 WebContents/CDP 预检 | UX fail-fast;不生成可信 receipt、不得升级为平台证明 | +| `electron/agent-browser/module.ts`, `electron/api/routes/agent-browser.ts`, `src/lib/agent-browser.ts`, and `src/pages/Chat/AgentBrowserPanel.tsx` | 项目级共享开发浏览器、可见 viewport 协调、Console/Network 诊断与 built artifact 临时预检 | 用户和 parent Agent 共享同一 sandboxed 页面;Main 校验当前项目、generation、bounds 与 CDP method。诊断按 owner 引用计数,关闭/切换/隐藏/休眠会释放 view 与 debugger;发布预检仍只是 UX fail-fast,不生成可信 receipt。 | | `electron/agent-browser/electron-adapter.ts` | Electron WebContents/CDP 设备指标、事件与临时 partition 适配 | probe 不挂载 UI,并在结束后销毁视图、清理隔离存储 | | `electron/services/project-packager.ts` | 受控项目扫描、静态 ZIP 生成和敏感/历史控制文件排除 | 只允许可发布 `ProjectType`,不提供 Compose 或手工 ZIP 路径 | | `electron/services/works-submission-binding.ts` | submission binding v2 持久化与旧 schema 迁移 | 旧中间态终止为 `legacy_retired`;文件名暂作安装兼容 | @@ -55,7 +56,7 @@ - Renderer UI → Renderer API contract → Main Host routes → Main services → Works Square;Renderer 不反向读取 Main 凭据、文件系统或归档。 - Project Configuration `插件` card → canonical `/project-config/plugins` same-page sheet → unified Renderer stores → bounded Main Marketplace and Device Package routes. `/plugins` and legacy URLs replace-redirect into deterministic filters and do not create a second lifecycle. -- Hosted Plugin parent tool → frozen Registry adapter → Main `GameResourceClient` → fixed Works Square game-resource routes. Stable logical operation identity survives response loss and Main restart; ambiguous submission remains reviewable and is never converted into an automatic fresh mutation. Saving a result uses the existing bounded project path and project write lease. +- Hosted Game Resource parent tool → frozen Registry adapter → Main delivery coordinator → `GameResourceClient` → fixed Works Square game-resource routes → durable local receipt → frozen project output directory. Stable logical operation identity survives response loss and Main restart; ambiguous submission remains reviewable and is never converted into an automatic fresh mutation. Main polls internally and acquires the shared project write lease only while materializing terminal outputs; resuming delivery never submits or charges again. - AI 编程 Renderer product Snapshot/commands → typed `/api/coding/*` Host API → Main Coding composition → target `CodingConversationRuntime` → shared Agent Server 内的目标 Pi 逻辑线程;Pi Provider 请求再经 Main AI proxy 访问模型上游。Renderer 不持有 Pi wire、凭据或本地 runtime URL。 - 普通项目创建只收集目录并使用内部 `interactive_ai_app` 默认与 Main-owned UUID;既有 Project Configuration 继续管理 Agent/Skill/知识/插件。用户可选地显式调用 Scaffold Skill 生成固定起步文件;Main release builder 生成 source/built/contract,服务端独立重算和校验决定发布安全。本地 `ProjectType` 或 Skill 准备度结论都不是授权结论。 - Built artifact preflight 检查最终上传的同字节快照,但客户端可被绕过且不产生可信 receipt;服务端仍是合同、摘要和不可变 Release 安全权威。 diff --git a/.project-docs/20-architecture/system-overview.md b/.project-docs/20-architecture/system-overview.md index 722e9d1..c2ff329 100644 --- a/.project-docs/20-architecture/system-overview.md +++ b/.project-docs/20-architecture/system-overview.md @@ -11,9 +11,10 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 | Renderer | 项目配置、一键提交状态 | 不接触账号 Token、ZIP、幂等键或本地绝对路径 | | Host API | 校验本地项目请求并投影安全响应 | 发布 mutation 还必须通过 Renderer capability;Host token/base 不能单独触发发布 | | Project Configuration | 目录选择式创建、Agent/Skill/知识与项目资源配置 | 新建流程不暴露类型、模板或身份选择:Renderer 写入内部默认 `interactive_ai_app`,Main 生成 UUID,并只创建 `.makelore/project.json` 与 `knowledge/`;既有 `custom` 与历史类型继续兼容 | -| Project Scaffold Skill | 显式生成固定版本的交互式 AI 应用起步文件,并提供发布准备度指导 | 官方 `makelore.project-scaffold` bundled Marketplace Plugin;完整预检、不覆盖、受控回滚,不安装依赖、不联网、不构建、不上传、不提审 | +| Project Scaffold Skill | 显式生成固定版本的交互式 AI 应用起步文件,并提供发布准备度指导 | 官方 `makelore.project-scaffold` bundled Marketplace Plugin;账号已获取且项目启用后自动提供给每个父 Agent,不需要伙伴分配,child 为空;它不是创建或聊天前置条件。完整预检、不覆盖、受控回滚,不安装依赖、不联网、不构建、不上传、不提审。 | | 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 | +| Token Points V2 | Main-owned `/api/works/billing/points` 安全投影 → Renderer 账号菜单 | 会员管理者可查看套餐、本周与总余额及刷新时间;非管理者只获得 `shared_available` 粗粒度状态,套餐、周期和精确点数统一投影为 `null` | | 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` 仅一版本回退 | @@ -24,7 +25,8 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 | Pi Conversation Runtime | 一个长驻 Pi `0.84.2` Agent Server 承载每条 active/warm Conversation 的隔离逻辑 Runtime/Session/JSONL channel | 严格 LF JSONL RPC、generation recovery、Snapshot hydration;正式包从 staged `pi-runtime` manifest/root 定位并校验 Pi 包入口;top-level 逻辑 turn 并发 4、warm idle LRU 8;Server 退出统一使旧 channel 失效并按需单实例重启 | | Pi Provider & Managed Resources | Provider catalog、thread-local secret projection、model/resource revision、Prompt/Skill/extension materialization、selected-model tools | 父凭据只进入选中逻辑线程的内存 credential store,child 凭据只进入该短命进程;Works `model_capabilities` 由 Main 严格归一化并作为安全 Provider metadata 持久化。Web Search 仅在精确 capability 存在时随冻结的 selected model/provider/credential 进入 parent tool catalog,并走普通模型计费;不回退 `agent_browser` 或独立 Hosted Provider。服务端 reasoning levels 优先于本地 profile,缺字段则清理 override 并回退;不扫描项目或用户的 `.pi/.agents/.codex`,不把 secret 或原始响应放进 argv、catalog 或 Renderer | | Pi Extension, Subagents & Lifecycle | 必需的生成式 Makelore extension、Main 显式选定的已安装 extensions、UI interaction、ephemeral child、write lease 与 background run lease | Makelore bridge 固定为首个 extension,其余选定 extension 全部经 Pi 的 explicit additional paths 加载且 ambient discovery 关闭;child 并发 4、单次最多 8、禁止递归;active/uncertain run 不因页面隐藏或 confirmation timeout 被停止,replacement/stop 必须可解释并清理所有 ownership | -| Official Bundled Plugins | Acquired code-owned bundled Game Resource / Project Scaffold package → project enablement and Agent assignment → effective parent snapshot | 两者都不经过设备下载、更新、Beta 或 artifact 签名。Game Resource 进入固定 Works Square hosted route;Project Scaffold 仅提供 Skill,其 `.mjs` 来自签名客户端固定资源。Marketplace 下载包仍只接受 P0 文本/图片资源。child 不继承 Plugin。 | +| Shared Agent Browser | Project-scoped sandboxed `WebContentsView`、Renderer 右侧面板与 Main-owned CDP bridge | 用户和 Agent 操作同一页面;Renderer 必须先提供可见 bounds,Agent `open` 最多等待 5 秒取得可见 viewport。Console/Network 诊断按 owner 引用计数;关闭面板、切换项目/模块、隐藏窗口或后台休眠会清理 view、debugger 与轮询。 | +| Code-owned Official Project Plugins | Existing Account acquisition or system-included delivery → project enablement → effective parent snapshot | Data Service、Game Resource 与 Project Scaffold 都不要求 Agent assignment,项目启用后自动进入每个父 Agent;child 不继承 Plugin。三者不经过设备下载、更新、Beta 或 artifact 签名;Game Resource 的一次确认由 Main 提交一次、内部轮询并把全部终态输出自动写入冻结的原项目,恢复本地交付不得重新生成或计费;Project Scaffold 的 `.mjs` 仍只来自签名客户端固定资源。需要分配的 Marketplace 下载包保持原规则。 | | Device Packages | Conversation install tools → Main-owned inspect/preview/confirm/commit → immutable local generation → parent Skill/Pi-extension resources | 支持 npm、Git、绝对本地 Plugin 目录与 loose `SKILL.md`;没有可见安装入口、Account Library、Release、Admission 或 Marketplace Package Store。可执行 extension 与非空 Skill `scripts/` 拥有桌面用户权限,必须披露并独立确认;生命周期脚本禁用。每个 generation 包含所有显式安装且当前启用的 Skill/extension;新/idle parent 自动刷新,active parent 在 turn settled 后刷新,child 始终为空。 | | Unified Plugin Workspace | Project Configuration 的 `插件` ResourceCard → `/project-config/plugins` same-page wide sheet | 复用统一 Plugin controller 与生命周期;Project Configuration 保持挂载,Code 侧栏不再提供独立入口;`/plugins` 和旧 Plugin URL 仅做保留查询条件的兼容重定向 | | AI Design Workspace & Living Form | 一个 Workspace 的当前 Direction、Current Specification、持久 Agent Session、conversation timeline、Tasks 与 Assets | 中央区域承载对话和唯一 active 制作方案,320–340 px 右侧 Works rail 承载 Workspace 导航;紧凑布局使用同一右侧 Sheet。Renderer 只持有草稿和已接受投影 | @@ -81,7 +83,7 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 - `ProjectType` 仍由项目配置持有且 UI/Host API 不提供类型变更。普通新建流程只要求目录并写入内部默认 `interactive_ai_app`;不向用户暴露类型、模板或项目 ID 选择。既有 `custom` 项目继续打开,历史 `mini_game` / `mini_program` 在读取边界归一为 `interactive_ai_app` 且不因读取被改写,缺少类型的旧配置归一为 `custom`。 - Main 为新项目自动生成 UUID,并为仅缺 `projectId` 的有效旧配置串行补齐;原始 ID、绑定/独立副本选择和 `initialized` gate 不属于普通界面。缺失或无效的其他 metadata 仍进入 Project Configuration。 - 项目创建只拥有 `.makelore/project.json` 和 `knowledge/`。交互式 AI 应用起步树由用户可选地明确调用 Scaffold Skill 生成;Skill 不是创建或进入聊天的前置条件,其发布准备度结论不执行也不替代 Main build/preflight/package/upload 或 Works Square 校验与审核。 -- `makelore.project-scaffold` 是代码所有的 bundled `skill_only` Marketplace Plugin。其官方 `.mjs` 只从固定客户端资源根加载;下载 artifact 的扩展名/资产校验没有放宽,仍拒绝 `.mjs`。 +- `makelore.data-service`、`makelore.game-resource` 与 `makelore.project-scaffold` 共用代码所有的项目级激活谓词;满足既有交付/获取条件并由项目启用后,其完整资源进入每个 parent Agent,不读取伙伴分配。Project Scaffold 是 bundled `skill_only` Marketplace Plugin,其官方 `.mjs` 只从固定客户端资源根加载;下载 artifact 的扩展名/资产校验没有放宽,仍拒绝 `.mjs`。 - 本地 `projectType` 只选择显式脚手架和内部构建路径,不是可信授权声明;Main 仍需安全打包,服务端仍需独立校验清单和包体。 - 云端确认上传成功后,本机 submission binding 失败只能产生固定、无路径的 `binding_warning`,不能把请求改判为失败;Renderer 仍继续轮询服务端校验与 Release 固化状态。 - 公共播放投影只有在上游 `playable === true`、版本名非空且 URL 通过同源 HTTPS、无 userinfo/loopback、长度、精确路径和无 query/fragment 校验时才可播放;不可信数据 fail closed。 diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md index d11e432..1abe07c 100644 --- a/.project-docs/30-worklog/current-state.md +++ b/.project-docs/30-worklog/current-state.md @@ -4,6 +4,21 @@ This file is the integrated default-branch snapshot. Feature tasks record progre ## Integrated Through +- Remote `main` frontier `da29294` is semantically integrated with local + frontier `f53683a` under task + `20260907-integrate-remote-before-push-a4c9e27b`. The merge retains the + local directory-only project creation, Main-owned UUID, direct `/chat` + entry, non-blocking no-Agent state, Project Configuration-owned Plugin + sheet, and retired Learning surface. It also incorporates the compatible + remote official project-wide Plugin activation, automatic Game Resource + delivery, Agent Browser restoration, and Token Points V2 projection. The + remote initialized/full-Agent Conversation gate is intentionally not + adopted: `initialized` remains schema compatibility only, while truly + invalid project metadata still routes to Project Configuration. Verification + passed typecheck, 1,912 full-suite tests with 3 skips plus pressure 1/1, lint + with 0 errors and 5 existing warnings, production Vite build, and targeted + Electron 6/6. No live paid Provider generation, installed-client smoke, + deployment, publication, or production database change is claimed. - September 6 completed product sources are integrated onto local `main` by task `20260907-merge-all-changes-8f3c2a`: project-gate removal `af13aca` via `39d7b7e`, Code landing `ce90f57` via `a91c7e3`, Plugin rehoming `be1764e` @@ -103,8 +118,12 @@ This file is the integrated default-branch snapshot. Feature tasks record progre install, build, upload, submit, or approve. Its `.mjs` uses the non-overridable application Node exposed as `MAKELORE_NODE_EXECUTABLE` and is executable only because it ships in the fixed signed-client resource root. Downloadable Marketplace artifacts - remain text/image-only and reject `.mjs`; acquisition, project enablement, Agent - assignment, and immutable bundled Release/Admission remain distinct. Main and Works + remain text/image-only and reject `.mjs`; acquisition, project enablement, and + immutable bundled Release/Admission remain distinct. Project Scaffold is project-wide + after enablement and has no Agent-assignment state; the same activation scope now also + covers Data Service and Game Resource, while Plugins outside the code-owned set retain + their own assignment rules. + Main and Works Square retain build, preflight, artifact, upload, immutable Release, and review authority. Exact integration verification passed the 12-test scaffold suite, 123 focused Vitest tests, typecheck, scoped ESLint, and the @@ -710,13 +729,13 @@ Prompt Museum 已退出当前产品面:Canvas 不再显示“获取灵感” Makelore 在会话恢复、登录和刷新后由 Electron Main 请求 Works `/api/auth/me`,Renderer 只获得 Code、Canvas、Robot 三个布尔权限。缺失 `module_access` 或任一字段时默认开启;服务端 `design` 显式映射客户端 `painting`,额外旧字段被忽略。被关闭的模块卡片置灰且不可点击,根路由、深层路由和别名路由均在 `MainLayout` 或模块初始化前阻断。Code provider 等待认证权限加载完成;权限查询返回终止性 `401` 时同时清理 Main 和 Renderer 会话。`/settings` 是全局设置,不受 Code 入口策略阻断。该机制只是客户端入口策略,不代替服务端 API 授权。 -插件工作区由 Project Configuration 页面拥有:模型、Skill、知识旁的“插件” ResourceCard 打开 `/project-config/plugins` 对应的同页宽 Sheet,配置页保持挂载;没有 active project 时仍可查看账号与本机插件。Code 侧栏不再提供独立入口,`/plugins`、`/plugin-marketplace`、`/my-plugins` 与 `/project-plugins` 只保留查询/筛选意图并兼容重定向。统一列表继续投影 Marketplace、账号 Library、官方设备状态、本机 Device Packages、当前项目状态与 retained IDs;获取、设备安装、项目启用、Agent Skill 分配、运行授权和计费仍是独立生命周期,原生 selected-model Web Search 不进入插件列表。 +插件工作区由 Project Configuration 页面拥有:模型、Skill、知识旁的“插件” ResourceCard 打开 `/project-config/plugins` 对应的同页宽 Sheet,配置页保持挂载;没有 active project 时仍可查看账号与本机插件。Code 侧栏不再提供独立入口,`/plugins`、`/plugin-marketplace`、`/my-plugins` 与 `/project-plugins` 只保留查询/筛选意图并兼容重定向。统一列表继续投影 Marketplace、账号 Library、官方设备状态、本机 Device Packages、当前项目状态与 retained IDs;获取、设备安装、项目启用、运行授权和计费仍是独立生命周期。Data Service、Game Resource 与 Project Scaffold 在满足交付/获取条件并由项目启用后自动进入每个父 Agent,不提供伙伴分配;采用 assignment scope 的其他插件继续由 Agent Skill 分配控制。Game Resource 在一次计费确认后由 Main 提交一次并内部轮询,终态全部输出自动写入调用时冻结的原项目;Agent 不再轮询状态、选择保存路径或进行第二次保存确认,交付恢复也不得重新生成或重复计费。原生 selected-model Web Search 不进入插件列表。 Learning 已从 Makelore 产品中移除:没有模块卡片、路由、侧栏、Renderer 页面、Main Host API、下载服务、共享 DTO、素材或打包 fallback。旧 Learning URL 回到模块选择页,旧 API 使用标准 404 边界。客户端不扫描、读取、迁移或自动删除历史课程数据;名称仍含 Learning 的冻结账号分区盐仅为跨模块持久状态兼容标识,不代表产品模块仍存在。 Code 入口现在始终落在 `/chat`。没有本地项目时显示 Makelore 品牌引导、单一橙色“新增项目”动作和真实项目卡横向列表;空列表不伪造示例。有项目但尚无 Agent 时保持项目可进入,并提供非阻塞的首个 Agent 设置入口。项目创建和项目卡选择都复用 Sidebar 的既有验证/激活流程,不建立第二套状态。 -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 仍未通过平台发布门禁。 +AI 编程已经硬切到精确 pin 的 Pi `0.84.2`,不存在 OpenCode fallback 或双 runtime。project、Agent 与 Conversation 只在 `.makelore/project.json` 和 `.makelore/conversations.json` 使用本地 schema v2;当前客户端不从 `.niancode` 或 `.opencode` 读取、迁移或删除项目元数据。只要项目 metadata 有效即可进入 `/chat`;`initialized` 仅为 schema 兼容字段,不构成导航或 Conversation gate。项目尚无启用且未归档的 Agent 时显示非阻塞设置入口,不创建伪 Agent;真正开始 Conversation 前仍由现有 Agent/模型校验给出明确恢复路径。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 仍未通过平台发布门禁。 Updater 仍由 Electron Main 选择目标 feed、记录原始诊断并保持失败语义。正式稳定源缺少对应平台 manifest 时,设置页只显示一条简洁中文提示并允许重试,不把缺包误报为已是最新版,也不向普通界面暴露堆栈、URL、路径或错误码;签名产物发布和真实升级安装仍属于外部 Release Gate。 diff --git a/.project-docs/30-worklog/tasks/20260907-integrate-remote-before-push-a4c9e27b.md b/.project-docs/30-worklog/tasks/20260907-integrate-remote-before-push-a4c9e27b.md index 3f73634..2fdbd3d 100644 --- a/.project-docs/30-worklog/tasks/20260907-integrate-remote-before-push-a4c9e27b.md +++ b/.project-docs/30-worklog/tasks/20260907-integrate-remote-before-push-a4c9e27b.md @@ -8,7 +8,7 @@ - Worktree: /Users/inmanx/Documents/makelore - Base commit: 6366fc28dc81a71ac60a71becfeee781fcd8890e - Owner: codex -- Status: Blocked pending human decision +- Status: Ready for integration gate ## Scope @@ -28,12 +28,13 @@ system. - Do not choose between contradictory product rules by timestamp, merge order, or an automatic `ours`/`theirs` strategy. -- Do not modify canonical project memory or product files until the user resolves - the conflict. +- The user confirmed on 2026-09-07 that the local non-blocking project-entry + behavior remains authoritative; reconcile all affected code, tests, and + canonical memory to that decision. ## Outcome -- Fetched both histories and confirmed divergence of 14 remote-only and 28 +- Fetched both histories and confirmed divergence of 14 remote-only and 29 local-only commits from their merge base. - Read all ten remote task records and the local project-creation source record. Remote official-plugin project scope, automatic game-resource delivery, Agent @@ -45,31 +46,42 @@ link. Remote source `8042d2b` integrated by `da29294` requires `initialized=true` plus a fully configured enabled Agent before Conversation entry and makes the first-Agent flow a required gate. -- Integration and push remain paused at the human-decision gate; no merge or - product/canonical edit has been made. +- The user confirmed directory-only creation, immediate `/chat`, non-blocking + no-Agent setup, and compatibility-only `initialized` as authoritative. +- Started an ordinary `--no-commit --no-ff` merge of `origin/main@da29294`. + The semantic resolution rejects the remote required-Agent readiness gate and + retains the compatible official project-wide Plugin activation, automatic + Game Resource delivery, Agent Browser restoration, and Token Points V2. +- Removed the remote-only task-record copies from the final tree without editing + them; they remain reachable through the remote merge parent. ## Verification - Concurrent Task Gate: passed; this worktree holds the exclusive integration lock and no peer owns the repository. - Remote fetch: passed; `origin/main` resolved to `da29294`. -- Divergence audit: 14 remote-only / 28 local-only commits. +- Divergence audit at merge start: 14 remote-only / 29 local-only commits. - Source-record semantic review: completed for all remote-only task records and local task `20260906-remove-project-gate-7c31`. -- Merge, product tests, typecheck, lint, build, and push: intentionally not run - while the semantic decision is unresolved. +- Conflict-marker and whitespace checks: passed after semantic resolution. +- Typecheck: passed with the exact pinned `pnpm@10.33.4` via Corepack. +- Focused unit tests: 346 passed and one Plugin-copy expectation failed; the + copy was aligned without changing behavior and its 17-test file then passed. +- Full unit suite: 226 files / 1,912 passed / 3 skipped; the isolated pressure + suite passed 1/1. +- Lint: passed with 0 errors and 5 pre-existing warnings. +- Production Vite build: passed for Renderer, Main, Preload, and utility worker. +- Targeted Electron E2E: 6/6 passed across first-chat/runtime behavior, Agent + Browser UI, project-config Skill details, and project-wide Plugin enablement. +- Project-doc gates, merge commit, remote race check, and push remain pending. ## Follow-ups -- Human confirmation is required on whether the current local non-blocking - project-entry behavior remains authoritative or the remote required-Agent - readiness gate should replace it. -- After confirmation, merge and reconcile the chosen rule, run focused readiness - and onboarding tests plus repository-required typecheck/lint/build/full tests, - complete the project-doc integration gate, and push `main:main` normally. +- Complete the project-doc integration gates, ordinary merge commit, remote race + check, and normal `main:main` push. ## Promotion Candidates -- Pending integration after the conflict is resolved: promote only the chosen - project-entry/readiness invariant; compatible remote promotions retain their - verified scope and constraints. +- Promote the confirmed non-blocking project-entry invariant together with the + compatible project-wide official Plugin, Game Resource delivery, Agent Browser, + and Token Points V2 boundaries recorded in the canonical documents. diff --git a/.project-docs/40-domain/business-rules.md b/.project-docs/40-domain/business-rules.md index 2893a32..9d7b29a 100644 --- a/.project-docs/40-domain/business-rules.md +++ b/.project-docs/40-domain/business-rules.md @@ -7,7 +7,11 @@ later consume Token Points, and system-included Data Service remains zero-charge. - Account Library, Device Installation, project enablement, Agent assignment, runtime authorization, and billing are separate states. No read, install, acquisition, or - assignment may silently advance another state. + assignment may silently advance another state. The code-owned project-wide identities + are `makelore.data-service`, `makelore.game-resource`, and + `makelore.project-scaffold`: their existing delivery/acquisition and project enablement + remain separate, but project enablement intentionally makes their Skills/tools + available to every parent Agent without creating or requiring assignment state. - Marketplace packages become effective only after closed manifest/descriptor parsing, canonical archive and client-compatibility checks, Ed25519 verification, immutable Package Store selection, project enablement, Agent projection, and current server @@ -18,11 +22,11 @@ worker or delete bytes it still owns. - System-included Data Service ships with MakeLore and has no Library acquisition, Admission, download, update, or device-uninstall action. Users may still enable it - per project and assign its Skill to an Agent. + per project; project enablement makes its Skill/tools available to every parent Agent. - Code-owned Game Resource is the optional bundled hosted Plugin. Its exact schema-2 manifest, Skill, and tools ship with MakeLore, so it has no device download, update, Beta, signature, or device-uninstall state. Account Library acquisition/removal, - project enablement, Agent assignment, current server policy, immutable Admission, + project enablement, current server policy, immutable Admission, explicit confirmation, and Token Point billing remain distinct. - Production Marketplace trust fails closed while the official Ed25519 public key is absent. Test-only/integration keys and packaged unknown-key rejection are evidence, @@ -30,8 +34,10 @@ implemented, but production package trust and real Provider activation remain closed until the official key and separate server pricing, credential, Admission, and acceptance gates are ready. -- A `platform_hosted` Skill or tool may enter only an installed, enabled, assigned, - trusted, compatible, policy-admitted parent Pi logical thread. Unknown, disabled, or +- A `platform_hosted` Skill or tool may enter only a delivered/installed, enabled, + trusted, compatible, policy-admitted parent Pi logical thread. Agent assignment is an + additional gate only for Plugin identities whose activation scope requires it; the + three code-owned project-wide identities bypass that gate. Unknown, disabled, or ineligible assignments stay inert; child workers receive no hosted Plugin projection. - A metered hosted mutation requires explicit client confirmation before resolve, Admission, or charging. Works Square owns price, payer, Token Point policy, and receipt @@ -41,8 +47,13 @@ - Electron Main to fixed Works Square routes is the only hosted Plugin transport. Packages, Renderer state, Pi arguments/results, logs, and saved project metadata must not expose Provider credentials, URLs, credit balances, raw responses, or Provider job - IDs. Saving a hosted result must use bounded project-relative paths and the existing - project write lease. + IDs. A confirmed Game Resource generation is one Main-owned submit-and-deliver + operation: submit once, poll internally, download every terminal output, and save it + below `assets/generated/game-resource//` in the project frozen at call + time. Provider/billing state and local delivery state remain separate. A delivery + retry or application restart may resume only local download/save work and must never + submit or charge again. The shared project write lease is held only while materializing + terminal outputs; the Agent does not choose paths, poll status, or confirm saving again. - Native Web Search is a selected-model capability, not a Marketplace Plugin. Only an exact verified capability may place `makelore_web_search` in a frozen parent worker; it uses that worker's current model/provider/credential and ordinary model billing. @@ -67,10 +78,19 @@ `插件` ResourceCard opens `/project-config/plugins` as a same-page wide sheet while the configuration page remains mounted. Code sidebar must not add a standalone Plugin entry; `/plugins` and older Plugin URLs are compatibility redirects only. +- 共享开发浏览器必须绑定当前项目和当前 generation。Renderer 的 `project_id` 与 + viewport presentation 仅可由具备 Renderer capability 的请求使用;Agent `open` + 必须等待真实可见 bounds 后才报告成功。非 Web 协议、文件注入、跨 target 与宿主级 + CDP 命令保持拒绝,诊断按 owner 引用计数;关闭面板、项目/模块切换、窗口隐藏或 + 后台休眠必须销毁 view、detach debugger 并停止无所有者轮询。 - Code-owned official bundled Plugins may be `platform_hosted` or `skill_only`. - `makelore.project-scaffold` retains Account Library, project enablement, Agent - assignment, Release, and Admission state while its exact Skill/templates/`.mjs` - ship only in the signed client. Downloadable Marketplace artifacts remain P0 + Data Service is system-included; Game Resource and `makelore.project-scaffold` retain + Account Library, project enablement, Release, and Admission state where applicable, + while their exact resources ship only in the signed client. All three are project-wide: + once their delivery/acquisition condition is satisfied and they are enabled for a + project, every parent Agent receives the full resource set without partner assignment; + child Agents remain empty. Downloadable + Marketplace artifacts remain P0 text/image-only and must reject `.mjs`; official bundled authority is not inferred from provider metadata or an uploaded ZIP. - 面向用户的 AI 编程新建流程只要求选择目录,不展示 `ProjectType`、模板、原始项目 UUID、绑定或独立副本选项。Renderer 写入内部默认 `interactive_ai_app`,Main 自动生成 UUID;创建后类型仍不能通过 UI 或 Host API 修改。既有 `custom` 项目继续可用,历史 `mini_game` / `mini_program` 仅在读取边界归一为 `interactive_ai_app` 且不改写配置;未传类型的底层兼容 API 调用仍按 `custom` 处理。 @@ -92,6 +112,7 @@ - 已发布作品优先读取 `play_url`,只有字段缺失时才使用一个客户端版本的 `runtime_url` 回退。公共播放 URL 必须是 Works Square 同源 HTTPS、无 userinfo/loopback、精确 `/apps/{encodeURIComponent(app_id)}/`、无 query/fragment,且上游明确 `playable === true` 并提供非空版本名;否则按不可播放处理。 - `works-cloud-deploy.json` 仅是已安装客户端的数据兼容文件名,不表示客户端仍提供 cloud deployment coordinator。 - Works Square 会话按真实键盘、鼠标或触摸活动滑动续期,连续 7 天未使用才要求重新授权。 +- 账号词元点数只能通过 Main-owned Works Square V2 余额路由投影。只有 `can_manage_membership=true` 的账号可向 Renderer 暴露套餐、周期、本周/永久/总点数;其他账号的这些字段必须统一为 `null`,并只以 `shared_available` 表达是否可用。格式或枚举不符合闭合 DTO 时整份响应 fail closed。 - “记住密码”是独立于七天会话的可选桌面凭据记录:只能由 Electron Main 在正式安装包中通过可用的系统安全存储加密落盘,账号密码不得进入 Renderer 持久状态、日志或 Works Square 持久化。退出登录和短信登录保留记录;只有成功的未勾选密码登录清除旧记录。系统安全存储不可用或未打包开发版必须禁用该选项。 - 运营端可按用户关闭 Code、Canvas 或 Robot 客户端入口,默认全开。Makelore 通过 Main-owned `/api/auth/me` 只消费三布尔安全投影;缺失 `module_access` 或字段按开启处理,服务端 `design` 对应现有客户端 `painting`,额外旧字段被忽略。 - 关闭的模块卡片必须置灰且无法点击;其根路由、深层路由和别名路由必须在 `MainLayout` 或模块初始化前阻断。Code provider 只能在 auth policy hydration 完成且 Code 已开启时初始化;`/settings` 是全局设置,不得随 Code 关闭而失去访问。 diff --git a/.project-docs/40-domain/glossary.md b/.project-docs/40-domain/glossary.md index 24f1d9b..1a0bd6e 100644 --- a/.project-docs/40-domain/glossary.md +++ b/.project-docs/40-domain/glossary.md @@ -4,6 +4,7 @@ |---|---|---| | `ProjectType` | 项目配置中持有且之后不可变的内部产品类型 | 普通新建不展示选择并默认 `interactive_ai_app`;既有 `custom` 继续兼容,历史 `mini_game` / `mini_program` 读取时归一为前者,缺少字段按 `custom` 处理 | | `BuildPreset` | 平台内部用于构建和验收项目包的受控实现 | 不等于 `ProjectType` 或 Scaffold 状态;交互式 AI 应用使用固定 Vite preset | +| Official Project Plugin | 由 MakeLore 代码所有、以项目启用作为生效范围的官方 Plugin | 当前闭合集合为 `makelore.data-service`、`makelore.game-resource`、`makelore.project-scaffold`;满足既有交付/获取条件并启用项目后自动进入每个 parent Agent,不使用伙伴分配,child 为空 | | Project Scaffold Skill | 官方 bundled Marketplace Plugin `makelore.project-scaffold` 中由用户明确调用、独立版本化的起步文件生成与发布准备度指导 | `.mjs` 只来自固定客户端资源;不覆盖路径、不安装/构建/上传/提审;项目创建服务和 Main/Works 发布权威不属于该 Skill | | Main-owned Release Build | Electron Main 对安全源码快照执行固定 npm 与项目 lockfile Vite 的本地构建 | Vite config/plugins 以桌面用户权限执行,不是 sandbox;Renderer 不获得路径、归档或 origin | | Artifact Contract | 与 source/built 双归档一并上传的严格版本化清单 | 服务端把三者视为不可信输入并独立逐字节重算、校验 | diff --git a/.project-docs/50-evidence/evidence-index.md b/.project-docs/50-evidence/evidence-index.md index 8d470ab..d9b5983 100644 --- a/.project-docs/50-evidence/evidence-index.md +++ b/.project-docs/50-evidence/evidence-index.md @@ -4,6 +4,8 @@ Use this index for searchable, traceable evidence records. | Date | Topic | Status | Source | Detail | |---|---|---|---|---| +| 2026-09-06 | Game Resource generation automatically delivers every output to the frozen project | Integrated on local `main`; live paid Provider and rebuilt installed-client smoke not run | Source `6113a2453299141eab8420a56e93675712dd607b`, product `4df4bc96245c01cd95e35ddf1b2b03d0e0d231c5`, source task `20260906-game-resource-auto-delivery-7a4e2c91`, integration task `20260906-game-resource-auto-delivery-integration-8c4e1a72`, ADR-008 | One confirmed generate call submits once; Main internally polls, downloads all terminal outputs, and writes them to the original frozen project. A durable receipt resumes local delivery without another Provider call or Token Point charge, and the shared write lease is held only during materialization. Agent-visible status/save tools and the second save confirmation are gone. Source evidence: 99 focused tests, 1,901 full unit tests/2 skips plus pressure, typecheck, lint, all Vite targets, 8 Electron tests, unified Plugins E2E 3/3, and Windows x64 runtime staging. No live paid generation, deploy, publication, push, installation, or packaged end-to-end smoke is claimed. | +| 2026-09-05 | Code-owned official project Plugins activate per project without partner assignment | Integrated on local `main`; rebuilt installed-client smoke pending | Initial Scaffold source/product `300ac89a81409440aac84ff45b1d9ca2fa186629` / `6710527e8f7150a6c4997d566a380454e33f455e`; broadened source/product `718783f6837e29f56c9add633596249c03e5701f` / `e0de7aa28c1d6e97454f0e4073ae9153e746bb4b`; tasks `20260905-official-plugin-project-scope-6e4a9c21` / `20260905-official-plugin-project-scope-integration-8b3d6f42`; ADR-008 | `makelore.data-service`, `makelore.game-resource`, and `makelore.project-scaffold` now enter every parent Agent after their existing delivery/acquisition and project-enable requirements, without partner assignment; child Agents remain empty and other package lifecycles are unchanged. TDD reproduced Main `skill_unassigned` and Renderer assignment-command failures. The broadened source passed 30 focused, 70 adjacent, and 37 resolver/composition tests, typecheck, lint, all Vite targets, and Electron E2E 1/1. The ordinary full unit run had one unrelated two-second real-process timing miss among 1,889 passes/2 skips; that file passed 6/6 alone. No rebuilt installed-client smoke is claimed. | | 2026-09-03 | Device Package packaged prepare 与全部已启用资源加载 | Integrated on local `main`; rebuilt installed-client activation pending | Prepare source `5a2f0eb6785b59d8b455ed5cb1d9773351ff895a`, activation source `17664c5fffcfe695653b4146503e645f54767c4b`, tasks `20260902-local-skill-install-fix-6b3e91a4` / `20260903-load-installed-resources-8f3c1a72`, verified candidate `bd377c9`, main promotion task `20260903-promote-installed-resources-main-5c8e1a72` | Packaged package inspection now uses the distributed physical Pi runtime instead of importing an incomplete `app.asar` graph; closed Device Package failures remain closed across the bridge. The parent Agent Server keeps its generated Makelore extension first, passes every further Main-selected installed/enabled extension through Pi `0.84.2` `additionalExtensionPaths`, retains every selected Skill path, and keeps ambient discovery off. Packaged prepare without commit passed for loose `SKILL.md`, npm, and Git/Ponytail; a real Agent Server loaded two external extensions and exposed both commands. Across the two source tasks, focused tests, 222 files / 1,815 full unit tests / 2 conditional skips, pressure, typecheck, scoped lint, production build, Windows packaging, and artifact/Pi verification passed. The currently installed 1.2.6 client was not replaced, so no live installed-client success is claimed. | | 2026-09-03 | Packaged Pi runtime-root resolver and local unsigned macOS arm64 artifact | Integrated locally; exact mounted-image bootstrap passed; signed release gate remains open | Source `5d7a235`, merge `4babd6d`, task `20260902-build-unsigned-mac-9d7e4a2c` | Node 24 did not honor the parent URL previously passed to `import.meta.resolve`, so an installed Agent Server searched beside the product resource script instead of staged `pi-runtime`. The replacement uses `findPackageJSON` from the explicit runtime manifest, selects the package import entry, rejects path escape, and imports the exact file URL. Focused real-process, full unit, typecheck, lint and Vite build passed at source. The corrected local-only unsigned DMG is 315,653,270 bytes with SHA-256 `6d0216da6c30f7fed537041b37c69811b8e025af3e9cccf85a64c690e29ecb7b`; direct initialize/shutdown passed both unpacked and from a read-only mounted image. It is not signed, notarized, published, or complete cross-platform evidence. | | 2026-09-03 | Makelore Code foreground Snapshot reconciliation and attention-only Conversation markers | Integrated locally; installed-app cause correlated and source regression suites passed | Foreground source `7aa118b` / merge `04320dd`, badge source `f2c3755` / merge `bbfe16d`, tasks `20260903-diagnose-recurring-stall-a83f5c21` and `20260902-conversation-badge-a7c49e` | The diagnosed installed run had already persisted an aborted terminal and Main had completed it with `project_deactivated`; no tool child or network work remained, while Renderer retained a cached `live` Snapshot. Coding now closes stale SSE on `lifecycle:sleep` and refreshes the selected Main-owned Snapshot on view/project context, visibility, and focus without replaying any mutation. Hidden red markers now arise only for a new pending interaction or a new completed/failed/aborted run, not assistant/thinking/tool progress or an individual tool failure. Source verification covered 45 focused foreground tests, 28 focused badge-store tests, full suites, typecheck, lint, production builds, and real Electron regressions. | diff --git a/.project-docs/80-commitments/commitments.md b/.project-docs/80-commitments/commitments.md index 29dd8c6..3f050c0 100644 --- a/.project-docs/80-commitments/commitments.md +++ b/.project-docs/80-commitments/commitments.md @@ -13,7 +13,7 @@ Track future-facing memory: promised follow-ups, unfinished loops, timed checks, | 2026-08-16 | 验收 Canvas 删除、Reference 与重报价的真实服务端链路 | 发布包含当前 Canvas V2 行为的安装包前 | 客户端/服务端集成 | Pending | 使用真实 Works 账号核对 editable final Prompt、`@图片N` Asset binding、缺失 binding 阻断、新 Specification revision 重报价和 Quote-ID 确认;删除 Workspace 后确认软删除可见性、未提交任务取消/预留积分释放、已运行任务结算。Prompt Museum 已退出产品面,不再要求 Museum UI/CDN/回填 smoke;dormant Main 媒体安全边界仍不得放宽。 | | 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` 回滚并记录支持矩阵 | | 2026-08-10 | 完成客户端提交到 App `play_url` 播放的真实生产整链验收 | source+built+contract 服务端协议、OSS immutable Release、CDN/Edge 与 App 消费链成组集成后 | 客户端/服务端集成 | Pending | 服务端先部署支持规范 `interactive_ai_app` 和历史别名的合同,再安装匹配客户端;使用真实账号执行目录式交互式 AI 应用创建、按项目需要可选调用 Scaffold Skill、客户端本地构建与同字节预检、双归档提交、服务端逐字节校验/不可变 Release 固化、运营批准、CDN 发布、App 播放与监控核对。普通新建不展示 `custom` 或旧 `mini_game` / `mini_program` 选项;旧值只验证兼容读取。如需不可绕过 runtime gate,另行设计可信 verifier。 | -| 2026-08-12 | Windows/macOS 发布流水线保留固定 npm/Pi 运行时与 Device Package 产物门禁 | 每次生成正式 Windows 或 macOS 安装器时 | 客户端发布 | Partial / Pending | 运行目标平台的 publish-runtime、artifact、Pi 与 final packaged proof;启动最终 Agent Server 并确认它从 staged `pi-runtime` manifest/root 初始化,再用安装后的首个 Conversation 确认不出现 runtime-unavailable 投影。安装 exact-main 客户端后,还需完成至少一个 npm/Git/loose-Skill prepare→confirm 流程,并以包含两个 extension 的已启用 Device Package generation 验证首个 Makelore bridge 与所有附加 extension/Skill 均生效、禁用项不加载、新/idle parent 刷新且 child 为空;同时完成 `makelore-project-scaffold` prepare→confirm→install→initialize,验证非空 Skill `scripts/` 披露、应用 Node 路径、既有文件不覆盖以及卸载不删除项目文件。源 `5d7a235` 已通过本地未签名 macOS arm64 DMG 的 read-only mounted-image initialize/shutdown,DMG SHA-256 为 `6d0216da6c30f7fed537041b37c69811b8e025af3e9cccf85a64c690e29ecb7b`;当前安装的 1.2.6 客户端早于 `5a2f0eb` / `17664c5` 修复。仍需签名/公证、完整 process proof、实际替换安装与首个 Conversation、新 Windows artifact,以及签名 macOS 和 native Linux 的脚手架 Skill 运行证据;不得以 prepare-only 或 workspace real-process 测试替代。 | +| 2026-08-12 | Windows/macOS 发布流水线保留固定 npm/Pi 运行时与 Device Package 产物门禁 | 每次生成正式 Windows 或 macOS 安装器时 | 客户端发布 | Partial / Pending | 运行目标平台的 publish-runtime、artifact、Pi 与 final packaged proof;启动最终 Agent Server 并确认它从 staged `pi-runtime` manifest/root 初始化,再用安装后的首个 Conversation 确认不出现 runtime-unavailable 投影。安装 exact-main 客户端后,还需完成至少一个 npm/Git/loose-Skill prepare→confirm 流程,并以包含两个 extension 的已启用 Device Package generation 验证首个 Makelore bridge 与所有附加 extension/Skill 均生效、禁用项不加载、新/idle parent 刷新且 child 为空;同时分别以 Data Service(随应用提供)、Game Resource / Project Scaffold(账号已获取)且项目已启用、未分配伙伴的状态打开新 parent Agent,验证三者自动获得完整 Skill/tool、child 为空,插件详情不显示伙伴分配;Project Scaffold 还需验证应用 Node 路径和既有文件不覆盖。源 `5d7a235` 已通过本地未签名 macOS arm64 DMG 的 read-only mounted-image initialize/shutdown,DMG SHA-256 为 `6d0216da6c30f7fed537041b37c69811b8e025af3e9cccf85a64c690e29ecb7b`;当前安装的 1.2.6 客户端早于 `5a2f0eb` / `17664c5` / `718783f` 修复。仍需签名/公证、完整 process proof、实际替换安装与首个 Conversation、新 Windows artifact,以及签名 macOS 和 native Linux 的脚手架 Skill 运行证据;不得以 prepare-only 或 workspace real-process 测试替代。 | | 2026-08-10 | 删除客户端 `runtime_url` 兼容回退 | 一个客户端兼容版本结束,且服务端与存量数据稳定提供 `play_url` | 客户端 | Pending | 删除类型字段、读取分支和对应回归测试 | | 2026-08-11 | 部署并验收 AI 绘画多 Conversation 服务端契约 | 发布包含多会话客户端之前 | 客户端/服务端集成 | Pending | 确认迁移 `0033`、Conversation list/create/read/turn/confirm API、持久 Agent Session 与事件字段已上线 | | 2026-08-12 | 复核 AI 编程 Provider 错误与重试投影 | 升级 Pi、Works gateway 或上游 Provider 错误格式时 | 客户端/Pi/provider 集成 | Pending | 重新验证确定性 Works user-context 缺失仍会 expire credential、非重试 fail fast、固定脱敏为 `CODING_PROVIDER_AUTH_REQUIRED`,并且不会被后续 `agent_settled` 改写为 completed;不得把其他 401/429/5xx 扩大归类。 | diff --git a/README.md b/README.md index effe08d..17449cb 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 工作台。新建 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` 继续保留。 +作品广场、素材广场、独立发布上传和云部署页面不属于 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 起步工程,不是创建前置条件,也不安装依赖、不联网、不构建、不上传或提审。交互式 AI 应用的项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。项目成果预览 `/deliverables` 继续保留。 ## 当前产品状态 @@ -18,6 +18,7 @@ Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工 - 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。 +- 账号词元点数:左下角账号菜单只通过 Main-owned `/api/works/billing/points` 读取 Works Square V2 余额。可管理会员的账号看到套餐、本周剩余/额度、总可用点数与刷新时间;家庭成员和青少年自有权益只收到“可用/已用尽”等粗粒度状态,不向 Renderer 暴露套餐周期或精确余额。 - 桌面性能策略:应用窗口使用不透明浅色表面并默认保留硬件合成;仅在显式安全模式或短时间内重复 GPU 进程崩溃时启用软件渲染,并把故障原因保存在用户数据目录。启动关键路径只创建本地应用壳,认证、代理、同步、更新和遥测在首帧后延迟初始化;开发版可通过 `app:performance` 快照观察 GPU、进程、WebContents、事件循环与 Renderer Long Task 聚合指标。 - 后台生命周期:Main 统一维护模块活动状态与任务租约。隐藏窗口、离开模块和关闭开发浏览器会释放非必要连接;生成、Code 执行、下载与发布构建持有租约并在完成后释放。已写入 Pi 的 prompt 或上下文整理即使确认超过 10 秒,也会继续持有运行所有权与后台租约,直到迟到响应、权威事件、明确失败或用户清理使其收敛;此时同一 Conversation 不接受重叠 mutation,其他 Conversation 不受影响。各模块的后台连接、轮询和子进程必须通过同一生命周期入口登记。 - 共享开发浏览器:AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network,支持本地与公网开发地址。 @@ -29,8 +30,8 @@ Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工 - AI 绘画生成:服务端从当前规格编译专业图片或视频指令并返回不可变 Quote;客户端只展示创作提示词、参考图绑定、媒介、画幅、数量、格式、时长、警告和设计点,不展示或改写供应商 Prompt、模型、价格原子或存储地址。修改方案会先提交规格并重新核价,确认时只提交 Quote 身份;确认后的方案不再常驻,而是进入时间线中的可展开制作记录。参考图行只负责缩略图、`@图片N` 状态、替换和删除,保留什么、改变什么等语义统一由创作提示词表达。任务与资产始终属于 Workspace。 - AI 绘画健壮性:Renderer 为每次命令生成稳定 operation id,网络结果未知时保留原命令供原样重试,不把未知写入当失败或创建第二次生成。Main 负责 Token 刷新、Agent Gateway REST 提交和有界 Run 查询,并将可恢复事件流投影为 Host API SSE;断线后按事件游标续接并重新读取权威 Workspace。Canvas 只使用 Works Square 云端 V2 契约,没有本地语义适配器或降级路径,上游不可用时明确报错。 - AI 绘画右侧全高作品栏只展示 Workspace,不再在项目下创建独立设计会话。删除时必须完整输入项目名称;删除后项目、Living Form、任务、参考图和生成作品会从账户中隐藏且无法访问,不影响用户已另存到磁盘的副本。删除当前项目后自动打开最近更新的剩余项目,删除最后一个项目后进入空状态。 -- 项目插件设置: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,测试使用注入的临时密钥。 +- 项目插件设置:Code 侧栏不再单列“插件”;配置 Agent、Skill、模型与知识的项目配置页内提供“插件”卡片,并在同页宽抽屉中投影官方目录、账号 Library、本机 Device Packages 与当前项目状态。深链使用 `/project-config/plugins` 打开该项目页抽屉;旧 `/plugins`、Marketplace、My Plugins、Project Plugins URL 只做保留查询条件的确定性重定向。来源身份保持 `official:`、`local:`、`retained:` 分离;账号获取、官方设备交付和项目启用仍分别写入既有 authority,不会自动推进下一步。Data Service、Game Resource 与 Project Scaffold 三个代码所有的官方插件在项目启用后对项目内所有父 Agent 生效,不提供伙伴分配;确实采用 assignment scope 的其他 Marketplace 插件继续由伙伴分配控制。任一来源失败不会清空其他来源;本机 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 不提供安装源选择器。 - 提示词博物馆已退出当前 Canvas 产品面:右侧作品栏不再提供“获取灵感”,历史 `/image-prompts` 地址统一返回 Canvas。Main-owned API、媒体校验和共享 DTO 暂作为不可见兼容/安全基础设施保留;服务端边界见 [`docs/prompt-museum-server-contract.md`](docs/prompt-museum-server-contract.md)。 @@ -104,17 +105,17 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p ### 共享开发浏览器 -- Electron Main 持有 sandboxed `WebContentsView`、项目级持久浏览器配置和按需 CDP 连接;被调试页面不获得 Makelore Preload、Node.js 能力或 Host API 凭证。 -- 用户和 Agent 操作同一个页面。Renderer 只负责显示、收起和布局;Agent 通过 Main 代理的页面级 CDP 工具导航、读取 Console/Network 和执行调试命令。 -- 非 Web 协议、文件注入、跨目标及宿主级命令会被阻止。面板关闭时销毁 `WebContentsView`、detach debugger 并释放页面;诊断域只在用户打开诊断视图时连接,面板重新打开时按 URL 和轻量历史元数据恢复。该能力独立于发布和部署。 +- Electron Main 持有 sandboxed `WebContentsView`、项目级持久浏览器配置和按需 CDP 连接;被调试页面不获得 Makelore Preload、Node.js 能力或 Host API 凭证。Renderer 只提交当前项目 id 和可见区域,真实项目路径仍由 Main 解析和校验。 +- 用户和 Agent 操作同一个页面。Agent 发起 `open` 后,Main 通知 Coding 右侧面板展开,并最多等待 5 秒取得当前 generation 的可见 bounds;展示失败会销毁当前视图并返回原始 `AgentBrowserFault` 代码,不会把隐藏页面伪装成可调试状态。Agent 通过 Main 代理的页面级 CDP 工具导航、读取 Console/Network 和执行调试命令。 +- 非 Web 协议、文件注入、跨目标及宿主级命令会被阻止。关闭面板、离开模块或项目、隐藏窗口、Renderer 丢失及后台休眠都会销毁 `WebContentsView`、detach debugger 并释放页面;关闭态没有浏览器轮询或 DOM observer。诊断域按 owner 计数,只在 Agent 当前运行或用户展开 Console/Network 时启用,运行结算/抽屉收起后释放;事件读取使用 5 秒长轮询、最小 drain backoff 和 500 条界面上限。该能力独立于发布和部署。 - 一键提交时,Makelore 会从待上传构建归档的同一组 Main-owned 内存字节启动临时回环站点,并在两个独立的临时 Chromium profile 中检查桌面和移动视口的主页面加载、运行错误、失败资源与白屏。临时页面不挂载到界面,不读取或写入用户浏览器的 Cookie、历史和登录态;检查结束后始终销毁并清理,也不要求用户预先打开开发预览。 - 客户端复用 Electron 内置 Chromium,不安装 Playwright 或额外浏览器。预检只改善提交前反馈,可被非官方客户端绕过,也不会上传“已通过”凭据;平台仍把源码、构建归档和清单视为不可信输入,逐字节重算并在人工审核后发布。安装包携带固定 npm 运行时,项目依赖和 Vite 版本由 `package-lock.json` 锁定;依赖准备需要本地网络。 ### 项目内置编码技能 - 产品内置四个核心编码技能根:`agent-browser`(开发浏览器)、`frontend-slides`(项目演示)、`grilling`(方案质询)和 `planning-with-files`(项目规划),统一从 vendor-neutral 的 `resources/coding-skills/` 打包。`data-service`(开发数据)由固定的 `resources/coding-plugins/data-service/` 插件包持有,不在核心技能根中复制路径或定义。 -- Marketplace 官方插件 Skill 从已签名 Package Store Release 或受控内置定义动态物化,不进入核心技能根;只有 parent worker 的冻结 effective snapshot 满足对应账号、设备、项目与伙伴条件时才生效,child worker 不继承 hosted tool。所选模型的 Web Search 与 Conversation 安装的设备包使用各自独立的 Main-owned 合同,不属于这条 Marketplace 生命周期。 -- 项目插件“启用”和把插件技能分配给智能体是两个独立动作:只有项目已启用 Data Service 时,未分配的 `data-service` 才可供新选择;禁用后,已有分配仍会显示并继续保存在智能体的 `skillIds`,但处于不可用且不生效的状态,重新启用后恢复生效。未启用的技能不进入该智能体的有效 Pi 资源集合。 +- Marketplace 官方插件 Skill 从已签名 Package Store Release 或受控内置定义动态物化,不进入核心技能根;parent worker 的冻结 effective snapshot 必须满足对应账号、设备、项目、Policy,以及该插件确实要求时的伙伴分配条件,child worker 不继承 hosted tool。所选模型的 Web Search 与 Conversation 安装的设备包使用各自独立的 Main-owned 合同,不属于这条 Marketplace 生命周期。 +- `makelore.data-service`、`makelore.game-resource` 与 `makelore.project-scaffold` 是代码所有的官方项目级例外:满足各自既有账号获取或随应用提供条件并在项目启用后,其完整 Skill/tool 集合自动进入该项目的每个 parent Agent,不要求或展示伙伴分配;禁用项目插件后未来 worker 不再加载,既有 assignment 数据可原样保留但不参与生效判断。游戏资源生成只需一次计费与项目写入确认,Main 会在后台完成状态查询并把全部输出自动保存到发起操作时的原项目;本地交付续作不会重新生成或扣费。其他 Marketplace 插件继续按自身 assignment 规则计算有效资源。 - `data-service` 只在用户显式请求后触发:先检查并说明最小集合,用户确认后配置一次、复制 SDK 资产,再用本地预览执行 put/read-back;它不用于已发布作品。 - 创建项目智能体时,`agent-browser`、`grilling` 与 `planning-with-files` 默认启用;`frontend-slides` 作为专项能力可手动启用。用户可以在创建或编辑智能体时调整选择。最终选择写入项目智能体的 `skillIds`。 - `grilling` 会在复杂实现前逐项确认高影响决策,用户确认前不执行变更。`planning-with-files` 只在复杂、可分阶段或需要跨会话恢复的任务中使用,并把 `task_plan.md`、`findings.md` 和 `progress.md` 直接保存到当前项目根目录,不写入 Skill 安装目录、用户目录或隐藏配置目录。 @@ -124,6 +125,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 新项目默认没有智能体;用户在项目内创建智能体时必须填写名称、预设头像或本地头像、职责说明和精确的 Provider 账号与默认模型。该选择以 `{ accountId, modelId, thinkingLevel }` 保存,不依赖运行时私有 Provider id。本地头像会自动裁剪为 256×256,并优先压缩为 WebP 后随项目配置保存;系统指令与技能属于高级设置。 - Makelore 为每个项目智能体附加平台管理的中文语言规则:自然语言默认使用简体中文,包括可见的思考过程、进度说明、工具调用说明和最终回复;代码、命令、路径、日志、错误原文与必要引用保留原始语言。用户或项目智能体系统指令明确要求其他语言时按该要求执行;模型仍不遵循时视为 Provider 模型行为。 +- 有效项目可以直接进入对话工作区;`initialized` 只作旧 schema 兼容,不参与导航。尚无可用智能体时,聊天页保留品牌引导并显示非阻塞的设置入口,不伪造智能体或 Conversation;用户可以稍后进入项目配置完成设置。 - 项目配置页的新增与已有智能体编辑统一使用居中弹窗;编辑保存先更新当前页面草稿,点击底部“保存项目配置”后统一持久化。“可用模型”抽屉只展示已配置模型、提供方和文本/多模态能力,不提供项目级选择。智能体的默认模型只作为新对话的初始值;核心聊天页可为当前对话独立切换模型和思考强度,切换不会改写智能体默认值或其他对话。 - 项目智能体只保存自身的精确默认模型;未完成模型选择的智能体保持待配置状态,不从项目级 `defaultModel` 或运行时私有配置回退。 - “可用技能”入口使用模块图标;点击技能后先展示目录结构,再展示主文件 `SKILL.md` 原文,并支持返回技能列表。 diff --git a/electron/agent-browser/module.ts b/electron/agent-browser/module.ts index 6da3d03..bb7ced0 100644 --- a/electron/agent-browser/module.ts +++ b/electron/agent-browser/module.ts @@ -106,6 +106,7 @@ interface BrowserRecord { title: string; visible: boolean; diagnosticsEnabled: boolean; + diagnosticOwners: Set; bounds: AgentBrowserBounds | null; error?: { code: AgentBrowserErrorCode; @@ -148,6 +149,7 @@ export interface AgentBrowserOpenInput { bounds?: AgentBrowserBounds; visible?: boolean; injectProjectData?: boolean; + diagnosticsOwner?: string; } export interface AgentBrowserPresentInput { @@ -156,6 +158,12 @@ export interface AgentBrowserPresentInput { bounds?: AgentBrowserBounds; } +export interface AgentBrowserWaitForPresentationInput { + projectPath: string; + generation: number; + timeoutMs?: number; +} + export interface AgentBrowserNavigateInput { projectPath: string; action: 'url' | 'back' | 'forward' | 'reload'; @@ -213,6 +221,7 @@ export class AgentBrowserModule { private readonly payloadStore: AgentBrowserPayloadStore; private readonly cdpGuard: AgentBrowserCdpGuard; private readonly eventWaiters = new Set<() => void>(); + private readonly presentationWaiters = new Set<() => void>(); private readonly commandCancellers = new Set<(fault: AgentBrowserFault) => void>(); private readonly lifecycleListeners = new Set(); private record: BrowserRecord | null = null; @@ -341,16 +350,32 @@ export class AgentBrowserModule { } if (this.record) { const record = this.record; - if (bounds) { - this.applyPresentation(record, input.visible ?? true, bounds); + const diagnosticOwner = input.diagnosticsOwner?.trim(); + const addedDiagnosticOwner = Boolean( + diagnosticOwner && !record.diagnosticOwners.has(diagnosticOwner), + ); + try { + if (diagnosticOwner) { + await this.updateDiagnosticOwner(record, diagnosticOwner, true); + } + if (bounds) { + this.applyPresentation(record, input.visible ?? true, bounds); + } + if (record.url !== targetUrl || record.error) { + await this.navigateTo(record, targetUrl); + } + return this.snapshot(record); + } catch (error) { + if (addedDiagnosticOwner && diagnosticOwner) { + await this.updateDiagnosticOwner(record, diagnosticOwner, false).catch(() => undefined); + } + throw error; } - if (record.url !== targetUrl || record.error) { - await this.navigateTo(record, targetUrl); - } - return this.snapshot(record); } const view = this.adapter.createView(agentBrowserPartition(projectPath)); + const diagnosticOwners = new Set(); + if (input.diagnosticsOwner?.trim()) diagnosticOwners.add(input.diagnosticsOwner.trim()); const record: BrowserRecord = { browserId: randomUUID(), projectId: input.projectId, @@ -362,7 +387,8 @@ export class AgentBrowserModule { url: targetUrl, title: '', visible: Boolean(bounds) && (input.visible ?? true), - diagnosticsEnabled: false, + diagnosticsEnabled: diagnosticOwners.size > 0, + diagnosticOwners, bounds, eventBuffer: new AgentBrowserEventBuffer(), childSessions: new Set(), @@ -439,14 +465,73 @@ export class AgentBrowserModule { } const bounds = input.bounds ? normalizeBounds(input.bounds) : record.bounds; this.applyPresentation(record, input.visible, bounds); + this.notifyPresentationWaiters(); return this.snapshot(record); }); } - setDiagnostics(input: { projectPath: string; enabled: boolean }): Promise { + waitForPresentation( + input: AgentBrowserWaitForPresentationInput, + ): Promise { + this.assertAvailable(); + const timeoutMs = normalizeIntegerRange( + input.timeoutMs, + MAX_WAIT_MS, + 1, + MAX_CDP_TIMEOUT_MS, + 'timeoutMs', + ); + const immediate = this.presentationSnapshot(input.projectPath, input.generation); + if (immediate) return Promise.resolve(immediate); + + return new Promise((resolvePromise, rejectPromise) => { + let settled = false; + let timer: ReturnType | undefined; + const cleanup = () => { + this.presentationWaiters.delete(check); + if (timer) clearTimeout(timer); + }; + const resolve = (snapshot: AgentBrowserSnapshot) => { + if (settled) return; + settled = true; + cleanup(); + resolvePromise(snapshot); + }; + const reject = (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + rejectPromise(error); + }; + const check = () => { + try { + const snapshot = this.presentationSnapshot(input.projectPath, input.generation); + if (snapshot) resolve(snapshot); + } catch (error) { + reject(error); + } + }; + this.presentationWaiters.add(check); + timer = setTimeout(() => { + reject(new AgentBrowserFault( + 'VIEWPORT_NOT_READY', + '开发浏览器显示区域没有及时准备好。', + true, + input.generation, + )); + }, timeoutMs); + check(); + }); + } + + setDiagnostics(input: { + projectPath: string; + enabled: boolean; + owner?: string; + }): Promise { return this.serialize(async () => { const record = this.requireRecord(input.projectPath); - await this.configureDiagnostics(record, input.enabled); + await this.updateDiagnosticOwner(record, input.owner ?? 'renderer', input.enabled); return this.snapshot(record); }); } @@ -766,6 +851,7 @@ export class AgentBrowserModule { generation: record.generation, url: record.url, }); + this.notifyPresentationWaiters(); } record.state = 'attaching'; record.error = undefined; @@ -1023,6 +1109,7 @@ export class AgentBrowserModule { url: record.url, }); this.notifyEventWaiters(); + this.notifyPresentationWaiters(); }; this.addDebuggerListener(record, 'message', onDebuggerMessage); this.addDebuggerListener(record, 'detach', onDebuggerDetach); @@ -1088,6 +1175,7 @@ export class AgentBrowserModule { code: 'DEVTOOLS_CONFLICT', message: '原生 DevTools 已打开,智能体调试暂时暂停。', }; + this.notifyPresentationWaiters(); }); this.addWebContentsListener(record, 'devtools-closed', () => { if (record !== this.record || record.state === 'closing' || record.state === 'crashed') { @@ -1112,6 +1200,7 @@ export class AgentBrowserModule { record.generation, ); record.error = { code: fault.code, message: fault.message }; + this.notifyPresentationWaiters(); } }).catch(() => undefined); }); @@ -1134,6 +1223,7 @@ export class AgentBrowserModule { record.childSessions.clear(); record.ioHandles.clear(); this.notifyEventWaiters(); + this.notifyPresentationWaiters(); }); this.addWebContentsListener(record, 'destroyed', () => { if (record !== this.record || record.state === 'closing') return; @@ -1152,9 +1242,29 @@ export class AgentBrowserModule { }); record.eventBuffer.markGap('view-recreated'); this.notifyEventWaiters(); + this.notifyPresentationWaiters(); }); } + private async updateDiagnosticOwner( + record: BrowserRecord, + ownerValue: string, + enabled: boolean, + ): Promise { + const owner = ownerValue.trim() || 'renderer'; + const hadOwner = record.diagnosticOwners.has(owner); + if (hadOwner === enabled) return; + if (enabled) record.diagnosticOwners.add(owner); + else record.diagnosticOwners.delete(owner); + try { + await this.configureDiagnostics(record, record.diagnosticOwners.size > 0); + } catch (error) { + if (hadOwner) record.diagnosticOwners.add(owner); + else record.diagnosticOwners.delete(owner); + throw error; + } + } + private async configureDiagnostics(record: BrowserRecord, enabled: boolean): Promise { if (record !== this.record || record.view.webContents.isDestroyed()) { throw new AgentBrowserFault( @@ -1520,6 +1630,56 @@ export class AgentBrowserModule { return record; } + private presentationSnapshot( + projectPath: string, + generation: number, + ): AgentBrowserSnapshot | null { + const record = this.requireRecord(projectPath); + if (record.generation !== generation) { + throw new AgentBrowserFault( + 'CLOSED', + '开发浏览器已切换到新的页面实例。', + true, + generation, + ); + } + if (record.state === 'crashed') { + throw new AgentBrowserFault( + 'RENDERER_CRASHED', + '开发浏览器页面进程已退出。', + true, + generation, + ); + } + if (record.state === 'suspended_devtools') { + throw new AgentBrowserFault( + 'DEVTOOLS_CONFLICT', + '请先关闭当前页面的原生 DevTools。', + true, + generation, + ); + } + if (record.state === 'detached_fault') { + throw new AgentBrowserFault( + 'DEBUGGER_BUSY', + '开发浏览器调试器尚未就绪。', + true, + generation, + ); + } + if (record.state === 'closing') { + throw new AgentBrowserFault( + 'CLOSED', + '开发浏览器正在关闭。', + true, + generation, + ); + } + return record.state === 'attached' && record.visible && record.bounds + ? this.snapshot(record) + : null; + } + private assertProject(record: BrowserRecord, projectPath: string): void { if (!samePath(record.projectPath, normalizeRequiredPath(projectPath))) { throw new AgentBrowserFault( @@ -1541,6 +1701,7 @@ export class AgentBrowserModule { const record = expected ?? this.record; if (!record) { this.notifyEventWaiters(); + this.notifyPresentationWaiters(); return; } if (expected && this.record !== expected) return; @@ -1555,6 +1716,7 @@ export class AgentBrowserModule { }); this.removeListeners(record); this.record = null; + this.notifyPresentationWaiters(); const interrupted = new AgentBrowserFault( 'CLOSED', '开发浏览器已关闭。', @@ -1580,6 +1742,7 @@ export class AgentBrowserModule { this.adapter.destroy(record.view); record.childSessions.clear(); record.ioHandles.clear(); + record.diagnosticOwners.clear(); record.eventBuffer.clear(); this.payloadStore.clear(); this.notifyEventWaiters(); @@ -1852,6 +2015,10 @@ export class AgentBrowserModule { for (const wake of waiters) wake(); } + private notifyPresentationWaiters(): void { + for (const wake of [...this.presentationWaiters]) wake(); + } + private notifyLifecycle(event: AgentBrowserLifecycleEvent): void { for (const listener of this.lifecycleListeners) { try { diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index 56c5b02..7885b60 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -1,6 +1,7 @@ import { accessSync, constants } from 'node:fs'; import path from 'node:path'; import type { AgentBrowserModule } from '../agent-browser'; +import type { AgentBrowserSnapshot } from '../../shared/agent-browser'; import { CodingAttachmentStore } from '../coding-projects/attachment-store'; import { createCodingConversationStore } from '../coding-projects/conversation-store'; import { CodingProjectService } from '../coding-projects/project-service'; @@ -11,6 +12,7 @@ import { } from '../coding-projects/project-store'; import { CodingConversationService } from '../coding-runtime/conversation-service'; import { PiManagedExtensionHost } from '../coding-runtime/pi/extension-host'; +import { PiProjectWriteLeaseCoordinator } from '../coding-runtime/pi/write-lease'; import { PiAgentServerProcess } from '../coding-runtime/pi/agent-server-process'; import { PiManagedInputRevisionCoordinator } from '../coding-runtime/pi/managed-input-revision'; import { PiProductTools } from '../coding-runtime/pi/product-tools'; @@ -46,6 +48,10 @@ import { import { createDataServicePluginAdapter } from '../coding-plugins/adapters/data-service'; import { createGameResourcePluginAdapter } from '../coding-plugins/adapters/game-resource'; import { GameResourceClient } from '../services/game-resource-client'; +import { + GameResourceDeliveryCoordinator, + GameResourceDeliveryReceiptStore, +} from '../services/game-resource-delivery'; import { AccountPluginCache } from '../coding-plugins/account-plugin-cache'; import { createMarketplaceClient, @@ -97,6 +103,8 @@ export interface CreateCodingCompositionOptions { accountCache?: AccountPluginCache; clientVersion?: string; policyClient?: PluginPolicyClient; + requestAgentBrowserPresentation?(snapshot: AgentBrowserSnapshot): void; + publishAgentBrowserState?(snapshot: AgentBrowserSnapshot): void; } type PiWorkerExecutableProbe = (candidate: string) => boolean; @@ -229,12 +237,15 @@ export function createCodingComposition( onGenerationChanged: async () => await invalidateDeviceResources(), }); const devicePackageTools = new DevicePackageTools(devicePackageManager); + const projectWriteLeases = new PiProjectWriteLeaseCoordinator(); const productTools = new PiProductTools({ browser: options.browser, attachments, bundledSkillsDir: options.paths.bundledSkillsDir, modelToolRegistry, devicePackageTools, + requestAgentBrowserPresentation: options.requestAgentBrowserPresentation, + publishAgentBrowserState: options.publishAgentBrowserState, pluginSkillSources, getPluginSkillSources: async () => effectiveResolver ? (await effectiveResolver.getSkillSources()).map((source) => ({ @@ -245,7 +256,7 @@ export function createCodingComposition( })) : [], }); - const extensionHost = new PiManagedExtensionHost(); + const extensionHost = new PiManagedExtensionHost(projectWriteLeases); extensionHost.configureProductTools(productTools); const conversationStores = new Map>(); const conversationStoreForProject = (projectPath: string) => { @@ -302,8 +313,26 @@ export function createCodingComposition( }); const dataService = createDataServiceOperations({ projects }); const dataServiceAdapter = createDataServicePluginAdapter(dataService); + const gameResourceClient = new GameResourceClient(); + const gameResourceDelivery = new GameResourceDeliveryCoordinator({ + client: gameResourceClient, + receipts: new GameResourceDeliveryReceiptStore(path.join( + options.paths.userDataDir, + 'coding-runtime', + 'game-resource', + 'receipts.json', + )), + leases: projectWriteLeases, + recordTouchedPaths: async (conversationId, runId, paths) => { + await productTools.recordTouchedPaths(conversationId, runId, paths); + }, + ...(options.acquireBackgroundLease + ? { acquireBackgroundLease: options.acquireBackgroundLease } + : {}), + }); const gameResourceAdapter = createGameResourcePluginAdapter({ - client: new GameResourceClient(), + client: gameResourceClient, + delivery: gameResourceDelivery, marketplace: marketplaceClient, packageStore, makeloreVersion: options.clientVersion ?? '2.0.0', @@ -474,8 +503,10 @@ export function createCodingComposition( await invalidateManagedResources(); }, }); + void gameResourceDelivery.resumePending().catch(() => undefined); const unsubscribeMarketplaceSession = subscribeWorksSquareSession(() => { void invalidateManagedResources(); + void gameResourceDelivery.resumePending().catch(() => undefined); }); const host = createCodingProductHost({ projects, @@ -517,9 +548,13 @@ export function createCodingComposition( runtime.dispose(conversationId, reason) ))); if (reason === 'background_sleep' && runtime.hasActiveWork()) return; + if (reason === 'background_sleep') { + await options.browser.close().catch(() => undefined); + } await agentServer.stop(); }, async shutdown() { + gameResourceDelivery.dispose(); previewDataSession?.dispose(); if (typeof options.browser.configurePreviewDataSession === 'function') { options.browser.configurePreviewDataSession(undefined); diff --git a/electron/api/routes/agent-browser.ts b/electron/api/routes/agent-browser.ts index 2091af9..8422b70 100644 --- a/electron/api/routes/agent-browser.ts +++ b/electron/api/routes/agent-browser.ts @@ -7,6 +7,7 @@ import { hasRendererCapability } from '../renderer-capability'; import { parseJsonBody, sendJson } from '../route-utils'; type AgentBrowserBody = { + project_id?: unknown; project_path?: unknown; url?: unknown; action?: unknown; @@ -71,7 +72,12 @@ function parseStringArray(value: unknown): string[] | undefined { return value.map((item) => item.trim()).filter(Boolean); } -async function resolveActiveProject(ctx: HostApiContext, requestedPath?: unknown) { +async function resolveActiveProject( + ctx: HostApiContext, + requestedPath?: unknown, + requestedId?: unknown, + rendererPresentation = false, +) { const activeProject = await ctx.codingProjectStore.getActiveProject(); if (!activeProject) { throw new AgentBrowserRouteError('PROJECT_NOT_ACTIVE', '请先打开一个项目。', 409); @@ -84,6 +90,24 @@ async function resolveActiveProject(ctx: HostApiContext, requestedPath?: unknown throw new AgentBrowserRouteError('PROJECT_NOT_ACTIVE', '当前项目目录不可用。', 409); } + const projectId = nonEmptyString(requestedId); + if (projectId) { + if (!rendererPresentation) { + throw new AgentBrowserRouteError( + 'TARGET_DENIED', + '项目 ID 只能由 Makelore 界面用于浏览器操作。', + 403, + ); + } + if (projectId !== activeProject.id) { + throw new AgentBrowserRouteError('PROJECT_MISMATCH', '智能体只能调试当前项目。', 403); + } + return { + ...activeProject, + path: activeRealPath, + }; + } + const requested = nonEmptyString(requestedPath); if (!requested) { throw new AgentBrowserRouteError('INVALID_REQUEST', '缺少当前项目路径。'); @@ -188,6 +212,19 @@ async function readBody(req: IncomingMessage): Promise { return await parseJsonBody(req); } +function resolveRequestProject( + req: IncomingMessage, + ctx: HostApiContext, + body: AgentBrowserBody, +) { + return resolveActiveProject( + ctx, + body.project_path, + body.project_id, + hasRendererCapability(req), + ); +} + export async function handleAgentBrowserRoutes( req: IncomingMessage, res: ServerResponse, @@ -200,7 +237,12 @@ export async function handleAgentBrowserRoutes( const service = requireService(ctx); if (url.pathname === '/api/agent-browser/state' && req.method === 'GET') { - const project = await resolveActiveProject(ctx, url.searchParams.get('project_path')); + const project = await resolveActiveProject( + ctx, + url.searchParams.get('project_path'), + url.searchParams.get('project_id'), + hasRendererCapability(req), + ); const browser = await service.getSnapshot(project.path); await ensureProjectStillActive(ctx, project); sendJson(res, 200, { success: true, browser }); @@ -209,8 +251,13 @@ export async function handleAgentBrowserRoutes( if (url.pathname === '/api/agent-browser/open' && req.method === 'POST') { const body = await readBody(req); - const project = await resolveActiveProject(ctx, body.project_path); const rendererPresentation = hasRendererCapability(req); + const project = await resolveActiveProject( + ctx, + body.project_path, + body.project_id, + rendererPresentation, + ); if (body.bounds !== undefined && !rendererPresentation) { requireRendererPresentation(req); } @@ -233,7 +280,7 @@ export async function handleAgentBrowserRoutes( if (url.pathname === '/api/agent-browser/present' && req.method === 'POST') { requireRendererPresentation(req); const body = await readBody(req); - const project = await resolveActiveProject(ctx, body.project_path); + const project = await resolveActiveProject(ctx, body.project_path, body.project_id, true); const browser = await service.present({ projectPath: project.path, visible: body.visible === true, @@ -248,7 +295,7 @@ export async function handleAgentBrowserRoutes( if (url.pathname === '/api/agent-browser/diagnostics' && req.method === 'POST') { requireRendererPresentation(req); const body = await readBody(req); - const project = await resolveActiveProject(ctx, body.project_path); + const project = await resolveActiveProject(ctx, body.project_path, body.project_id, true); const browser = await service.setDiagnostics({ projectPath: project.path, enabled: body.enabled === true, @@ -260,7 +307,7 @@ export async function handleAgentBrowserRoutes( if (url.pathname === '/api/agent-browser/navigate' && req.method === 'POST') { const body = await readBody(req); - const project = await resolveActiveProject(ctx, body.project_path); + const project = await resolveRequestProject(req, ctx, body); const action = nonEmptyString(body.action); if (action !== 'url' && action !== 'back' && action !== 'forward' && action !== 'reload') { throw new AgentBrowserRouteError('INVALID_REQUEST', '浏览器导航动作无效。'); @@ -278,7 +325,7 @@ export async function handleAgentBrowserRoutes( if (url.pathname === '/api/agent-browser/cdp/send' && req.method === 'POST') { const body = await readBody(req); - const project = await resolveActiveProject(ctx, body.project_path); + const project = await resolveRequestProject(req, ctx, body); const method = nonEmptyString(body.method); if (!method) throw new AgentBrowserRouteError('INVALID_REQUEST', '缺少 CDP method。'); const params = body.params === undefined @@ -302,7 +349,7 @@ export async function handleAgentBrowserRoutes( if (url.pathname === '/api/agent-browser/cdp/events' && req.method === 'POST') { const body = await readBody(req); - const project = await resolveActiveProject(ctx, body.project_path); + const project = await resolveRequestProject(req, ctx, body); const page = await service.readEvents({ projectPath: project.path, after: finiteInteger(body.after), @@ -317,7 +364,7 @@ export async function handleAgentBrowserRoutes( if (url.pathname === '/api/agent-browser/payload/read' && req.method === 'POST') { const body = await readBody(req); - const project = await resolveActiveProject(ctx, body.project_path); + const project = await resolveRequestProject(req, ctx, body); const handle = nonEmptyString(body.handle); if (!handle) throw new AgentBrowserRouteError('INVALID_REQUEST', '缺少 payload handle。'); const chunk = await service.readPayload({ @@ -333,7 +380,7 @@ export async function handleAgentBrowserRoutes( if (url.pathname === '/api/agent-browser/close' && req.method === 'POST') { const body = await readBody(req); - const project = await resolveActiveProject(ctx, body.project_path); + const project = await resolveRequestProject(req, ctx, body); const browser = await service.close(project.path); await ensureProjectStillActive(ctx, project); emitState(ctx, 'agent-browser:state', { @@ -347,7 +394,7 @@ export async function handleAgentBrowserRoutes( if (url.pathname === '/api/agent-browser/reset-profile' && req.method === 'POST') { const body = await readBody(req); - const project = await resolveActiveProject(ctx, body.project_path); + const project = await resolveRequestProject(req, ctx, body); const browser = await service.resetProfile(project.path); await ensureProjectStillActive(ctx, project); emitState(ctx, 'agent-browser:state', { diff --git a/electron/api/routes/works.ts b/electron/api/routes/works.ts index b2dc78f..bc7267c 100644 --- a/electron/api/routes/works.ts +++ b/electron/api/routes/works.ts @@ -60,6 +60,27 @@ const AGENT_AVATAR_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp' const MAX_PROJECT_COVER_BYTES = 10 * 1024 * 1024; const PROJECT_COVER_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']); const SAFE_PROJECT_STATUSES = new Set(['draft', 'published']); +const TOKEN_POINT_FIELDS = [ + 'weekly_allowance', + 'weekly_used', + 'weekly_reserved', + 'weekly_remaining', + 'permanent_total', + 'permanent_used', + 'permanent_reserved', + 'permanent_remaining', + 'total_remaining', +] as const; +const TOKEN_POINT_METADATA_FIELDS = [ + 'plan_code', + 'plan_name', + 'cycle_start', + 'cycle_end', + 'next_refresh_at', +] as const; +const TOKEN_POINT_ENTITLEMENT_SOURCES = new Set(['self', 'family_owner', 'shared_group']); +const TOKEN_POINT_UPGRADE_ACTIONS = new Set(['self_service', 'contact_family_owner']); +const TOKEN_POINT_VALUE_PATTERN = /^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/u; function readRequiredString(value: unknown, field: string): string { if (typeof value !== 'string' || !value.trim()) { @@ -267,6 +288,62 @@ function readNullableStringField( return typeof value === 'string' ? value : undefined; } +function readNullablePointField( + source: Record, + field: string, +): string | null | undefined { + const value = source[field]; + if (value === undefined || value === null) return null; + if (typeof value !== 'string') return undefined; + const normalized = value.trim(); + return TOKEN_POINT_VALUE_PATTERN.test(normalized) ? normalized : undefined; +} + +function projectSafeTokenPointBalance(value: unknown): Record | null { + if (!isRecord(value)) return null; + + const entitlementSource = readOptionalString(value.entitlement_source); + const upgradeAction = readOptionalString(value.upgrade_action); + if ( + !entitlementSource + || !TOKEN_POINT_ENTITLEMENT_SOURCES.has(entitlementSource) + || typeof value.family_shared !== 'boolean' + || typeof value.can_manage_membership !== 'boolean' + || !upgradeAction + || !TOKEN_POINT_UPGRADE_ACTIONS.has(upgradeAction) + ) return null; + + if ( + value.shared_available !== undefined + && value.shared_available !== null + && typeof value.shared_available !== 'boolean' + ) return null; + + const canManageMembership = value.can_manage_membership; + if (!canManageMembership && typeof value.shared_available !== 'boolean') return null; + + const projected: Record = {}; + for (const field of TOKEN_POINT_METADATA_FIELDS) { + const fieldValue = canManageMembership ? readNullableStringField(value, field) : null; + if (fieldValue === undefined) return null; + projected[field] = fieldValue; + } + for (const field of TOKEN_POINT_FIELDS) { + const fieldValue = canManageMembership ? readNullablePointField(value, field) : null; + if (fieldValue === undefined) return null; + projected[field] = fieldValue; + } + + return { + ...projected, + entitlement_source: entitlementSource, + family_shared: value.family_shared, + can_manage_membership: canManageMembership, + upgrade_action: upgradeAction, + shared_available: canManageMembership ? null : value.shared_available, + }; +} + function projectSafeProject( value: unknown, options: { requireStatus?: boolean } = {}, @@ -614,12 +691,12 @@ async function handleListMyProjects( sendJson(res, response.status, { success: true, page }); } -async function handleGetBillingTokenUsage( +async function handleGetBillingTokenPoints( req: IncomingMessage, res: ServerResponse, ): Promise { const accessToken = readRequiredHeader(req, 'x-niancode-access-token'); - const response = await proxyAwareFetch(createWorksUrl('/api/billing/token-usage').toString(), { + const response = await proxyAwareFetch(createWorksUrl('/api/billing/points').toString(), { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, @@ -627,11 +704,16 @@ async function handleGetBillingTokenUsage( }); if (!response.ok) { - await sendUpstreamError(res, response, `Works Square token usage failed (${response.status})`); + await sendUpstreamError(res, response, `Works Square token points failed (${response.status})`); return; } - sendJson(res, response.status, { success: true, usage: await readResponsePayload(response) }); + const points = projectSafeTokenPointBalance(await readResponsePayload(response)); + if (!points) { + sendJson(res, 502, { success: false, error: 'Works Square returned an invalid token point balance' }); + return; + } + sendJson(res, response.status, { success: true, points }); } async function handleAgentProfile( @@ -1413,8 +1495,8 @@ export async function handleWorksRoutes( return true; } - if (url.pathname === '/api/works/billing/token-usage' && req.method === 'GET') { - await handleGetBillingTokenUsage(req, res); + if (url.pathname === '/api/works/billing/points' && req.method === 'GET') { + await handleGetBillingTokenPoints(req, res); return true; } diff --git a/electron/coding-plugins/adapters/game-resource.ts b/electron/coding-plugins/adapters/game-resource.ts index 4cbfb37..8aa6b0a 100644 --- a/electron/coding-plugins/adapters/game-resource.ts +++ b/electron/coding-plugins/adapters/game-resource.ts @@ -1,5 +1,5 @@ import { Buffer } from 'node:buffer'; -import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import type { CodingPluginToolDefinition } from '../../../shared/coding-plugins'; import { PiGameAssetTools } from '../../coding-runtime/pi/extensions/game-assets'; @@ -14,6 +14,11 @@ import { GameResourceClientError, type GameResourceGeneration, } from '../../services/game-resource-client'; +import type { + GameResourceDeliveryCoordinator, + GameResourceDeliveryProgress, + GameResourceDeliveryResult, +} from '../../services/game-resource-delivery'; import type { AdapterInvocationResult, CodingPluginAdapter, @@ -30,6 +35,7 @@ type Input = Record; export interface GameResourcePluginAdapterOptions { readonly client: GameResourceClient; + readonly delivery: GameResourceDeliveryCoordinator; readonly marketplace: MarketplacePackageClientPort; readonly packageStore: Pick; readonly makeloreVersion: string; @@ -89,6 +95,20 @@ function generationData(value: GameResourceGeneration, includeBilling: boolean) }; } +function deliveryData( + value: GameResourceDeliveryResult | GameResourceDeliveryProgress, +) { + return { + executionId: value.executionId, + providerStatus: value.providerStatus, + deliveryStatus: value.deliveryStatus, + outputCount: value.outputCount, + files: value.files, + ...('phase' in value ? { phase: value.phase } : {}), + ...(value.errorCode === undefined ? {} : { errorCode: value.errorCode }), + }; +} + function clientFailure(error: unknown): AdapterInvocationResult { if (error instanceof GameResourceClientError) { return failure(error.code, error.message, error.status, error.retryable); @@ -191,6 +211,7 @@ export class GameResourcePluginAdapter implements CodingPluginAdapter { context: TrustedCodingCapabilityContext, tool: CodingPluginToolDefinition, input: unknown, + onProgress?: (result: AdapterInvocationResult) => void, ): Promise { if (!isRecord(input)) return failure('plugin_input_invalid', 'Game-resource tool input is invalid', 422, false); try { @@ -215,24 +236,41 @@ export class GameResourcePluginAdapter implements CodingPluginAdapter { return failure('plugin_input_invalid', 'Game-resource generation input is invalid', 422, false); } const admission = await this.admission(context); - const generated = await this.options.client.generate({ - ...admission, - projectId: context.durableProjectId, - logicalOperationId: context.requestId, - kind, - templateName: input.templateName, - templateConfig: templateConfig(input.templateConfigJson), - requirement: input.requirement, - ...(typeof input.aspectRatio === 'string' ? { aspectRatio: input.aspectRatio } : {}), - ...(typeof input.temperature === 'number' ? { temperature: input.temperature } : {}), - ...(typeof input.jobName === 'string' ? { jobName: input.jobName } : {}), - ...(typeof input.modelName === 'string' ? { modelName: input.modelName } : {}), - ...(typeof input.resolution === 'string' ? { resolution: input.resolution } : {}), - ...(typeof input.hdRemoveBgMode === 'string' ? { hdRemoveBgMode: input.hdRemoveBgMode } : {}), - ...(typeof input.threadId === 'string' ? { threadId: input.threadId } : {}), - referenceFiles: await references(context.projectPath, input.referencePaths), + const generated = await this.options.delivery.generateAndMaterialize({ + context: { + conversationId: context.conversationId, + runId: context.runId, + localProjectId: context.localProjectId, + durableProjectId: context.durableProjectId, + projectPath: context.projectPath, + logicalOperationId: context.requestId, + }, + request: { + ...admission, + projectId: context.durableProjectId, + logicalOperationId: context.requestId, + kind, + templateName: input.templateName, + templateConfig: templateConfig(input.templateConfigJson), + requirement: input.requirement, + ...(typeof input.aspectRatio === 'string' ? { aspectRatio: input.aspectRatio } : {}), + ...(typeof input.temperature === 'number' ? { temperature: input.temperature } : {}), + ...(typeof input.jobName === 'string' ? { jobName: input.jobName } : {}), + ...(typeof input.modelName === 'string' ? { modelName: input.modelName } : {}), + ...(typeof input.resolution === 'string' ? { resolution: input.resolution } : {}), + ...(typeof input.hdRemoveBgMode === 'string' ? { hdRemoveBgMode: input.hdRemoveBgMode } : {}), + ...(typeof input.threadId === 'string' ? { threadId: input.threadId } : {}), + referenceFiles: await references(context.projectPath, input.referencePaths), + }, + ...(onProgress ? { + onProgress: (progress) => onProgress(success( + deliveryData(progress), + 202, + progress.billing, + )), + } : {}), }); - if (generated.status === 'failed' || generated.status === 'cancelled') { + if (generated.providerStatus === 'failed' || generated.providerStatus === 'cancelled') { return failure( generated.errorCode ?? 'game_resource_generation_failed', 'Hosted game-resource generation was rejected', @@ -242,41 +280,16 @@ export class GameResourcePluginAdapter implements CodingPluginAdapter { ); } return success( - generationData(generated, false), - generated.status === 'succeeded' ? 200 : 202, + deliveryData(generated), + generated.deliveryStatus === 'saved' ? 200 : 202, generated.billing, ); } - case 'game_resource_status': { - const executionId = this.executionId(input.executionId); - const generated = await this.options.client.get(executionId); - return success(generationData(generated, true)); - } case 'game_resource_cancel': { const executionId = this.executionId(input.executionId); const generated = await this.options.client.cancel(executionId); return success(generationData(generated, true)); } - case 'game_resource_save_output': { - if (input.confirmed !== true) return failure('confirmation_required', 'Explicit save confirmation is required', 400, false); - const executionId = this.executionId(input.executionId); - const outputIndex = input.outputIndex === undefined ? undefined : input.outputIndex; - if (outputIndex !== undefined && (!Number.isSafeInteger(outputIndex) || (outputIndex as number) < 0 || (outputIndex as number) > 99)) { - return failure('plugin_input_invalid', 'Game-resource output index is invalid', 422, false); - } - const target = relativeProjectPath(context.projectPath, input.relativePath); - const content = await this.options.client.download(executionId, outputIndex as number | undefined); - await mkdir(path.dirname(target.absolute), { recursive: true }); - try { - await writeFile(target.absolute, content.bytes, { flag: 'wx' }); - } catch (error) { - if (isRecord(error) && error.code === 'EEXIST') { - return failure('game_resource_destination_exists', 'Destination file already exists', 409, false); - } - throw error; - } - return success({ savedPath: target.relative, bytes: content.bytes.byteLength }); - } case 'game_asset_browser': { const result = await this.gameAssets.browse(context.projectPath, input, context.requestId); const parsed = JSON.parse(result.content[0]?.text ?? '{}') as unknown; diff --git a/electron/coding-plugins/effective-resolver.ts b/electron/coding-plugins/effective-resolver.ts index 24c96ef..51d3424 100644 --- a/electron/coding-plugins/effective-resolver.ts +++ b/electron/coding-plugins/effective-resolver.ts @@ -3,6 +3,7 @@ import type { CodingPluginDefinition, CodingPluginToolDefinition, } from '../../shared/coding-plugins'; +import { isProjectWideCodingPluginId } from '../../shared/coding-plugins'; import { CORE_CODING_SKILL_IDS, type CodingSkillId, @@ -240,6 +241,15 @@ function marketplaceSkillConflicts(records: readonly DefinitionRecord[]): Readon return blocked; } +function selectedSkillsForDefinition( + definition: CodingPluginDefinition, + assignedSkillIds: readonly string[], +): CodingPluginDefinition['skills'] { + return isProjectWideCodingPluginId(definition.id) + ? definition.skills + : definition.skills.filter(({ id }) => assignedSkillIds.includes(id)); +} + /** * Resolve a worker snapshot from separated Marketplace, Package Store, * project, assignment, and policy state. This module deliberately performs @@ -303,7 +313,7 @@ export class EffectivePluginResolver { const enabled = new Set(await this.enabledPluginIds(input.projectPath)); const requiresLibrary = userDefinitions.some(({ definition, installed }) => ( installed && enabled.has(definition.id) - && definition.skills.some(({ id }) => assigned.includes(id)) + && selectedSkillsForDefinition(definition, assigned).length > 0 )); const library = input.library !== undefined ? input.library @@ -314,7 +324,8 @@ export class EffectivePluginResolver { const serverDefinitions = definitions.filter(({ definition }) => definition.requiresBackend); const needsPolicyRefresh = policyState.status !== 'current' && serverDefinitions.some(({ definition, installed }) => ( - installed && enabled.has(definition.id) && definition.skills.some(({ id }) => assigned.includes(id)) + installed && enabled.has(definition.id) + && selectedSkillsForDefinition(definition, assigned).length > 0 )); if (needsPolicyRefresh && this.options.policyClient) { await this.options.policyClient.refresh(); @@ -330,7 +341,7 @@ export class EffectivePluginResolver { )); continue; } - const selectedSkills = definition.skills.filter(({ id }) => assigned.includes(id)); + const selectedSkills = selectedSkillsForDefinition(definition, assigned); if (selectedSkills.length === 0) { unavailableReasons.push(unavailable(definition.id, 'skill_unassigned', 'Plugin Skill is not assigned')); continue; diff --git a/electron/coding-plugins/registry.ts b/electron/coding-plugins/registry.ts index 34321ab..8bf1161 100644 --- a/electron/coding-plugins/registry.ts +++ b/electron/coding-plugins/registry.ts @@ -89,6 +89,7 @@ export interface CodingPluginAdapter { context: TrustedCodingCapabilityContext, tool: CodingPluginToolDefinition, input: unknown, + onProgress?: (result: AdapterInvocationResult) => void, ): Promise; deactivate?(projectPath: string): Promise; } @@ -124,6 +125,7 @@ export interface CodingCapabilityRegistryPort { workerRole: 'parent' | 'child'; effectiveSkillIds: readonly string[]; value: unknown; + onUpdate?: (result: PiProductToolResult) => void; }): Promise; } @@ -530,6 +532,7 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor workerRole: 'parent' | 'child'; effectiveSkillIds: readonly string[]; value: unknown; + onUpdate?: (result: PiProductToolResult) => void; }): Promise { let indexed = this.toolsByName.get(input.toolName); const validId = requestId(input.context) !== 'invalid-request-id'; @@ -638,7 +641,30 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor }; let result: AdapterInvocationResult; try { - result = await adapter.invoke(trustedContext, tool, input.value); + const reportProgress = input.onUpdate ? (progress: AdapterInvocationResult) => { + let progressBilling: CapabilityBillingReceiptV1; + if (catalogPolicy.billing.mode === 'platform_metered') { + if (!progress.billing || !validBillingReceipt(progress.billing) + || progress.billing.mode !== 'platform_metered') return; + progressBilling = progress.billing; + } else { + progressBilling = policyEntered(catalogPolicy.billing.mode); + } + try { + input.onUpdate?.(buildCapabilityToolResult( + definition, + tool, + input.context, + progress, + progressBilling, + )); + } catch { + // Progress is observational and must not change the capability outcome. + } + } : undefined; + result = reportProgress + ? await adapter.invoke(trustedContext, tool, input.value, reportProgress) + : await adapter.invoke(trustedContext, tool, input.value); } catch { return this.resultFailure(definition, tool, input.context, 'plugin_backend_unavailable', 'Plugin adapter is temporarily unavailable', 503, true, baseBilling); } diff --git a/electron/coding-runtime/pi/extension-host.ts b/electron/coding-runtime/pi/extension-host.ts index 6133ef1..9072a07 100644 --- a/electron/coding-runtime/pi/extension-host.ts +++ b/electron/coding-runtime/pi/extension-host.ts @@ -18,6 +18,7 @@ import type { CodingPluginToolDefinition } from '../../../shared/coding-plugins' import type { PiSkillEntry } from './resource-loader'; import type { EffectivePluginSnapshot } from '../../coding-plugins/effective-resolver'; import { DevicePackageError } from '../../coding-packages/device-package-manager'; +import { AgentBrowserFault } from '../../agent-browser/fault'; const MAX_REQUEST_BYTES = 64 * 1024; const PRODUCT_TOOL_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9._:-]{0,63}$/u; @@ -93,7 +94,7 @@ interface SubagentBridgeRequest { } interface ProductToolBridgeRequest { - action: 'product.invoke'; + action: 'product.invoke' | 'product.invoke.stream'; conversationId: string; workerGeneration: number; runId: string; @@ -142,7 +143,7 @@ function bridgeRequest(value: unknown): value is BridgeRequest { && typeof value.resourceId === 'string'; if (!common) return false; if (value.action === 'subagent.dispatch') return 'request' in value; - if (value.action === 'product.invoke') { + if (value.action === 'product.invoke' || value.action === 'product.invoke.stream') { return typeof value.toolName === 'string' && PRODUCT_TOOL_NAME_PATTERN.test(value.toolName) && 'input' in value; @@ -417,7 +418,7 @@ export class PiManagedExtensionHost { this.respond(response, 200, { recorded: true }); return; } - if (value.action === 'product.invoke') { + if (value.action === 'product.invoke' || value.action === 'product.invoke.stream') { if (record.role !== 'parent') { this.respond(response, 403, { error: 'Child workers cannot invoke parent product tools' }); return; @@ -431,7 +432,7 @@ export class PiManagedExtensionHost { this.respond(response, 503, { error: 'Product tools are unavailable' }); return; } - const productResult = await this.productTools.execute(value.toolName, { + const context = { conversationId: record.conversationId, workerGeneration: record.generation, runId: value.runId, @@ -440,7 +441,38 @@ export class PiManagedExtensionHost { projectPath: record.projectPath, skillIds: record.skillIds, ...(record.effectiveSnapshot ? { effectiveSnapshot: record.effectiveSnapshot } : {}), - }, value.input); + }; + if (value.action === 'product.invoke.stream') { + response.writeHead(200, { + 'content-type': 'application/x-ndjson; charset=utf-8', + ...(this.closing ? { connection: 'close' } : {}), + }); + try { + const productResult = await this.productTools.execute( + value.toolName, + context, + value.input, + (result) => { + if (!response.writableEnded && !response.destroyed) { + response.write(`${JSON.stringify({ result })}\n`); + } + }, + ); + if (!response.writableEnded && !response.destroyed) { + response.end(`${JSON.stringify({ result: productResult, done: true })}\n`); + } + } catch { + if (!response.writableEnded && !response.destroyed) { + response.end(`${JSON.stringify({ error: 'Product tool invocation failed' })}\n`); + } + } + return; + } + const productResult = await this.productTools.execute( + value.toolName, + context, + value.input, + ); this.respond(response, 200, { result: productResult }); return; } @@ -485,6 +517,17 @@ export class PiManagedExtensionHost { record.waiters.delete(value.resourceId); } } catch (error) { + if (!response.writableEnded && error instanceof AgentBrowserFault) { + const message = error.message.slice(0, 512); + this.respond(response, 400, { + code: error.code, + error: `${error.code}: ${message}`, + retryable: error.retryable, + ...(error.generation === undefined ? {} : { generation: error.generation }), + ...(error.outcome === undefined ? {} : { outcome: error.outcome }), + }); + return; + } if (!response.writableEnded && error instanceof DevicePackageError) { this.respond(response, 400, { code: error.code, diff --git a/electron/coding-runtime/pi/extensions/agent-browser.ts b/electron/coding-runtime/pi/extensions/agent-browser.ts index 2eadc71..d4f85b6 100644 --- a/electron/coding-runtime/pi/extensions/agent-browser.ts +++ b/electron/coding-runtime/pi/extensions/agent-browser.ts @@ -4,10 +4,17 @@ import type { CodingAttachmentStore } from '../../../coding-projects/attachment- import type { AgentBrowserDetailsV1 } from '../../contracts'; export interface AgentBrowserToolContext { + conversationId: string; + runId: string; projectId: string; projectPath: string; } +export type AgentBrowserPresentationRequester = (snapshot: AgentBrowserSnapshot) => void; +export type AgentBrowserStatePublisher = (snapshot: AgentBrowserSnapshot) => void; + +const PRESENTATION_TIMEOUT_MS = 5_000; + export interface AgentBrowserToolResult { content: Array<{ type: 'text'; text: string }>; details: AgentBrowserDetailsV1; @@ -70,11 +77,27 @@ async function readPayloadJson( } export class PiAgentBrowserTool { + private readonly diagnosticRuns = new Map(); + constructor( private readonly browser: AgentBrowserModule, private readonly attachments: CodingAttachmentStore, + private readonly requestPresentation?: AgentBrowserPresentationRequester, + private readonly publishState?: AgentBrowserStatePublisher, ) {} + async releaseRun(conversationId: string, runId: string): Promise { + const key = `${conversationId}:${runId}`; + const diagnostic = this.diagnosticRuns.get(key); + if (!diagnostic) return; + this.diagnosticRuns.delete(key); + await this.browser.setDiagnostics({ + projectPath: diagnostic.projectPath, + enabled: false, + owner: diagnostic.owner, + }).catch(() => undefined); + } + async execute( context: AgentBrowserToolContext, input: unknown, @@ -85,20 +108,63 @@ export class PiAgentBrowserTool { return result(action, publicSnapshot(await this.browser.getSnapshot(context.projectPath))); } if (action === 'open') { - const snapshot = await this.browser.open({ + const owner = `agent:${context.conversationId}:${context.runId}`; + let snapshot = await this.browser.open({ projectId: context.projectId, projectPath: context.projectPath, url: string(body.url, true) as string, visible: false, + diagnosticsOwner: owner, ...(body.injectProjectData === true ? { injectProjectData: true } : {}), }); + const runKey = `${context.conversationId}:${context.runId}`; + this.diagnosticRuns.set(runKey, { projectPath: context.projectPath, owner }); + if (this.requestPresentation) { + try { + this.requestPresentation(snapshot); + snapshot = await this.browser.waitForPresentation({ + projectPath: context.projectPath, + generation: snapshot.generation, + timeoutMs: PRESENTATION_TIMEOUT_MS, + }); + } catch (error) { + this.diagnosticRuns.delete(runKey); + const closed = await this.browser.close(context.projectPath).catch(() => null); + if (closed) { + this.publishState?.({ + ...closed, + projectId: context.projectId, + projectPath: context.projectPath, + }); + } + throw error; + } + } return result(action, publicSnapshot(snapshot)); } if (action === 'close') { - return result(action, publicSnapshot(await this.browser.close(context.projectPath))); + for (const [key, diagnostic] of this.diagnosticRuns) { + if (diagnostic.projectPath === context.projectPath) this.diagnosticRuns.delete(key); + } + const snapshot = await this.browser.close(context.projectPath); + this.publishState?.({ + ...snapshot, + projectId: context.projectId, + projectPath: context.projectPath, + }); + return result(action, publicSnapshot(snapshot)); } if (action === 'reset_profile') { - return result(action, publicSnapshot(await this.browser.resetProfile(context.projectPath))); + for (const [key, diagnostic] of this.diagnosticRuns) { + if (diagnostic.projectPath === context.projectPath) this.diagnosticRuns.delete(key); + } + const snapshot = await this.browser.resetProfile(context.projectPath); + this.publishState?.({ + ...snapshot, + projectId: context.projectId, + projectPath: context.projectPath, + }); + return result(action, publicSnapshot(snapshot)); } if (action === 'navigate') { const navigation = string(body.navigation, true); @@ -110,6 +176,7 @@ export class PiAgentBrowserTool { action: navigation as 'url' | 'back' | 'forward' | 'reload', ...(body.url === undefined ? {} : { url: string(body.url) }), }); + this.publishState?.(snapshot); return result(action, publicSnapshot(snapshot)); } if (action === 'read_events') { diff --git a/electron/coding-runtime/pi/extensions/makelore-runtime.ts b/electron/coding-runtime/pi/extensions/makelore-runtime.ts index d8e6d65..9330c42 100644 --- a/electron/coding-runtime/pi/extensions/makelore-runtime.ts +++ b/electron/coding-runtime/pi/extensions/makelore-runtime.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { atomicWriteText } from '../../../coding-projects/atomic-json'; -export const MAKELORE_PI_EXTENSION_VERSION = 5; +export const MAKELORE_PI_EXTENSION_VERSION = 6; export const MAKELORE_PI_EXTENSION_FILENAME = `makelore-runtime-v${MAKELORE_PI_EXTENSION_VERSION}.mjs`; const BUNDLE_SOURCE = String.raw` @@ -180,15 +180,78 @@ export function createMakeloreRuntime(runtimeDefaults = {}) { return response.result; } - function registerProductTool(name, label, description, parameters, projectWriteLease = false) { + async function invokeProductStream(toolCallId, toolName, input, signal, onUpdate) { + const context = await runtimeContext(); + const bridgeUrl = runtimeValue(RUNTIME_FLAGS.bridgeUrl); + const workerToken = runtimeValue(RUNTIME_FLAGS.workerToken); + if (!bridgeUrl || !workerToken) throw new Error('Makelore runtime bridge is unavailable'); + const response = await fetch(bridgeUrl, { + method: 'POST', + headers: { + authorization: 'Bearer ' + workerToken, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + ...context, + action: 'product.invoke.stream', + resourceId: toolCallId, + toolName, + input, + }), + signal, + }); + if (!response.ok) { + const result = await response.json().catch(() => ({})); + throw new Error(result.error || 'Makelore runtime bridge rejected the request'); + } + if (!response.body) throw new Error('Makelore product stream returned no body'); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffered = ''; + let finalResult; + let completed = false; + while (true) { + const { value, done } = await reader.read(); + buffered += decoder.decode(value || new Uint8Array(), { stream: !done }); + let newline = buffered.indexOf('\n'); + while (newline >= 0) { + const line = buffered.slice(0, newline); + buffered = buffered.slice(newline + 1); + if (line) { + const item = JSON.parse(line); + if (typeof item.error === 'string') throw new Error(item.error); + if (item.result) { + finalResult = item.result; + if (item.done) completed = true; + else onUpdate?.(item.result); + } + } + newline = buffered.indexOf('\n'); + } + if (done) break; + } + if (!completed || !finalResult) throw new Error('Makelore product stream ended before completion'); + return finalResult; + } + + function registerProductTool( + name, + label, + description, + parameters, + projectWriteLease = false, + streamsProgress = false, + ) { pi.registerTool({ name, label, description, parameters, ...(projectWriteLease ? { executionMode: 'sequential' } : {}), - async execute(toolCallId, params, signal) { - return await invokeProduct(toolCallId, name, params, signal); + async execute(toolCallId, params, signal, onUpdate) { + return streamsProgress + ? await invokeProductStream(toolCallId, name, params, signal, onUpdate) + : await invokeProduct(toolCallId, name, params, signal); }, }); } @@ -229,6 +292,7 @@ export function createMakeloreRuntime(runtimeDefaults = {}) { declaration.description, declaration.inputSchema, dynamicLeaseTools.has(declaration.name), + declaration.executionMode === 'job', ); } } diff --git a/electron/coding-runtime/pi/product-tools.ts b/electron/coding-runtime/pi/product-tools.ts index 36970ac..08cba64 100644 --- a/electron/coding-runtime/pi/product-tools.ts +++ b/electron/coding-runtime/pi/product-tools.ts @@ -23,6 +23,10 @@ import type { EffectivePluginSnapshot } from '../../coding-plugins/effective-res import type { KnownToolDetails, RuntimeContextDetailsV1 } from '../contracts'; import { BUNDLED_CODING_SKILL_IDS } from '../../../shared/coding-skills'; import { PiAgentBrowserTool } from './extensions/agent-browser'; +import type { + AgentBrowserPresentationRequester, + AgentBrowserStatePublisher, +} from './extensions/agent-browser'; import { reportChangedFiles } from './extensions/changed-file'; import { projectTaskState } from './extensions/task-state'; import type { ModelToolRegistryPort } from './model-tools/model-tool-registry'; @@ -73,6 +77,8 @@ export interface PiProductToolsOptions { capabilityRegistry?: CodingCapabilityRegistry; modelToolRegistry?: ModelToolRegistryPort; devicePackageTools?: DevicePackageTools; + requestAgentBrowserPresentation?: AgentBrowserPresentationRequester; + publishAgentBrowserState?: AgentBrowserStatePublisher; } export class PiProductTools { @@ -82,7 +88,12 @@ export class PiProductTools { constructor(private readonly options: PiProductToolsOptions) { this.changeTracker = options.changeTracker ?? new ConversationChangeTracker(); - this.browser = new PiAgentBrowserTool(options.browser, options.attachments); + this.browser = new PiAgentBrowserTool( + options.browser, + options.attachments, + options.requestAgentBrowserPresentation, + options.publishAgentBrowserState, + ); this.capabilityRegistry = options.capabilityRegistry; } @@ -94,8 +105,12 @@ export class PiProductTools { return this.changeTracker.beginRun(input); } - settleRun(conversationId: string, runId: string) { - return this.changeTracker.settleRun(conversationId, runId); + async settleRun(conversationId: string, runId: string) { + try { + return await this.changeTracker.settleRun(conversationId, runId); + } finally { + await this.browser.releaseRun(conversationId, runId); + } } getChanges(conversationId: string): ConversationChangesSnapshot | null { @@ -148,6 +163,7 @@ export class PiProductTools { toolName: PiProductToolName | string, context: PiProductToolContext, input: unknown, + onUpdate?: (result: PiProductToolResult) => void, ): Promise { if (DEVICE_PACKAGE_TOOL_NAMES.includes(toolName as typeof DEVICE_PACKAGE_TOOL_NAMES[number])) { if (!this.options.devicePackageTools) throw new Error('Device package management is unavailable'); @@ -176,6 +192,7 @@ export class PiProductTools { workerRole: 'parent', effectiveSkillIds: context.skillIds, value: input, + ...(onUpdate ? { onUpdate } : {}), }); } if (toolName !== 'runtime_context') throw new Error('Product tool is unavailable'); diff --git a/electron/coding-runtime/pi/release-proof.ts b/electron/coding-runtime/pi/release-proof.ts index cfbbc15..0784662 100644 --- a/electron/coding-runtime/pi/release-proof.ts +++ b/electron/coding-runtime/pi/release-proof.ts @@ -1416,7 +1416,7 @@ export async function runFinalAsarExtensionProof(): Promise { storage: codingProjectStorage, projectStore: codingProjectStore, browser: agentBrowser, + requestAgentBrowserPresentation: (snapshot) => { + hostEventBus.emit('agent-browser:show', snapshot); + if (!window.isDestroyed() && !window.webContents.isDestroyed()) { + window.webContents.send('agent-browser:show', snapshot); + } + }, + publishAgentBrowserState: (snapshot) => { + hostEventBus.emit('agent-browser:state', snapshot); + if (!window.isDestroyed() && !window.webContents.isDestroyed()) { + window.webContents.send('agent-browser:state', snapshot); + } + }, getLocalProxyCredential: () => getHostApiToken() || undefined, acquireBackgroundLease: (lease) => backgroundLifecycle!.acquireLease(lease), paths: { diff --git a/electron/services/game-resource-delivery.ts b/electron/services/game-resource-delivery.ts new file mode 100644 index 0000000..8e376e5 --- /dev/null +++ b/electron/services/game-resource-delivery.ts @@ -0,0 +1,471 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { CapabilityBillingReceiptV1 } from '../../shared/data-service'; +import { atomicWriteJson, readJsonFile } from '../coding-projects/atomic-json'; +import type { PiProjectWriteLeaseCoordinator } from '../coding-runtime/pi/write-lease'; +import type { + GameResourceClient, + GameResourceGenerateInput, + GameResourceGeneration, +} from './game-resource-client'; + +const RECEIPT_SCHEMA_VERSION = 1; +const MAX_RECEIPTS = 1_000; +const MAX_LOCAL_DELIVERY_ATTEMPTS = 3; +const LOCAL_DELIVERY_RETRY_DELAY_MS = 1_000; +const ACTIVE_PROVIDER_STATUSES = new Set([ + 'reserved', + 'accepted', + 'running', +]); + +type HostedBillingReceipt = Extract; +type DeliveryStatus = 'not_started' | 'waiting' | 'saving' | 'saved' | 'delivery_failed'; + +export interface GameResourceDeliveryContext { + readonly conversationId: string; + readonly runId: string; + readonly localProjectId: string; + readonly durableProjectId: string; + readonly projectPath: string; + readonly logicalOperationId: string; +} + +export interface GameResourceDeliveredFile { + readonly path: string; + readonly bytes: number; +} + +export interface GameResourceDeliveryResult { + readonly executionId: string; + readonly providerStatus: GameResourceGeneration['status']; + readonly deliveryStatus: DeliveryStatus; + readonly outputCount: number; + readonly files: readonly GameResourceDeliveredFile[]; + readonly billing: HostedBillingReceipt; + readonly errorCode?: string; +} + +export interface GameResourceDeliveryProgress extends GameResourceDeliveryResult { + readonly phase: 'submitted' | 'generating' | 'saving' | 'saved'; +} + +interface DeliveryReceipt { + readonly logicalOperationId: string; + readonly executionId: string; + readonly conversationId: string; + readonly runId: string; + readonly localProjectId: string; + readonly durableProjectId: string; + readonly projectPath: string; + readonly providerStatus: GameResourceGeneration['status']; + readonly outputCount: number; + readonly pollIntervalSeconds: number; + readonly billing: HostedBillingReceipt; + readonly deliveryStatus: DeliveryStatus; + readonly files: readonly GameResourceDeliveredFile[]; + readonly errorCode?: string; + readonly updatedAt: string; +} + +interface ReceiptFile { + readonly schemaVersion: 1; + readonly receipts: readonly DeliveryReceipt[]; +} + +type WriteOutput = (filePath: string, bytes: Uint8Array) => Promise; +type RecordTouchedPaths = ( + conversationId: string, + runId: string, + paths: readonly string[], +) => Promise; +type Sleep = (milliseconds: number, signal?: AbortSignal) => Promise; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isReceipt(value: unknown): value is DeliveryReceipt { + if (!isRecord(value)) return false; + return typeof value.logicalOperationId === 'string' + && typeof value.executionId === 'string' + && typeof value.conversationId === 'string' + && typeof value.runId === 'string' + && typeof value.localProjectId === 'string' + && typeof value.durableProjectId === 'string' + && typeof value.projectPath === 'string' + && typeof value.providerStatus === 'string' + && Number.isSafeInteger(value.outputCount) + && Number.isSafeInteger(value.pollIntervalSeconds) + && isRecord(value.billing) + && typeof value.deliveryStatus === 'string' + && Array.isArray(value.files) + && typeof value.updatedAt === 'string'; +} + +function resultFrom(receipt: DeliveryReceipt): GameResourceDeliveryResult { + return { + executionId: receipt.executionId, + providerStatus: receipt.providerStatus, + deliveryStatus: receipt.deliveryStatus, + outputCount: receipt.outputCount, + files: receipt.files, + billing: receipt.billing, + ...(receipt.errorCode ? { errorCode: receipt.errorCode } : {}), + }; +} + +function extensionFor(fileName: string | null, contentType: string): string { + const named = fileName ? path.extname(fileName).toLowerCase() : ''; + if (['.png', '.webp', '.jpg', '.jpeg', '.gif'].includes(named)) return named; + const byType: Readonly> = { + 'image/png': '.png', + 'image/webp': '.webp', + 'image/jpeg': '.jpg', + 'image/gif': '.gif', + }; + return byType[contentType.split(';', 1)[0]?.trim().toLowerCase() ?? ''] ?? '.bin'; +} + +async function defaultWriteOutput(filePath: string, bytes: Uint8Array): Promise { + await writeFile(filePath, bytes, { flag: 'wx' }); +} + +async function defaultSleep(milliseconds: number, signal?: AbortSignal): Promise { + await new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error('Game Resource delivery interrupted')); + return; + } + const finish = () => { + signal?.removeEventListener('abort', abort); + resolve(); + }; + const timeout = setTimeout(finish, milliseconds); + const abort = () => { + clearTimeout(timeout); + signal?.removeEventListener('abort', abort); + reject(new Error('Game Resource delivery interrupted')); + }; + signal?.addEventListener('abort', abort, { once: true }); + }); +} + +export class GameResourceDeliveryReceiptStore { + private writeQueue: Promise = Promise.resolve(); + + constructor(private readonly filePath: string) {} + + async get(logicalOperationId: string): Promise { + return (await this.read()).receipts.find( + (receipt) => receipt.logicalOperationId === logicalOperationId, + ) ?? null; + } + + async list(): Promise { + return (await this.read()).receipts; + } + + async put(receipt: DeliveryReceipt): Promise { + const operation = this.writeQueue.then(async () => { + const current = await this.read(); + const remaining = current.receipts.filter( + (item) => item.logicalOperationId !== receipt.logicalOperationId, + ); + const receipts = [...remaining, receipt] + .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt)) + .slice(-MAX_RECEIPTS); + await atomicWriteJson(this.filePath, { + schemaVersion: RECEIPT_SCHEMA_VERSION, + receipts, + } satisfies ReceiptFile); + }); + this.writeQueue = operation.catch(() => undefined); + await operation; + } + + private async read(): Promise { + let value: unknown; + try { + value = await readJsonFile(this.filePath); + } catch (error) { + if (isRecord(error) && error.code === 'ENOENT') { + return { schemaVersion: RECEIPT_SCHEMA_VERSION, receipts: [] }; + } + throw error; + } + if (!isRecord(value) || value.schemaVersion !== RECEIPT_SCHEMA_VERSION + || !Array.isArray(value.receipts) || !value.receipts.every(isReceipt)) { + throw new Error('Game Resource delivery receipt file is invalid'); + } + return value as unknown as ReceiptFile; + } +} + +export interface GameResourceDeliveryCoordinatorOptions { + readonly client: Pick; + readonly receipts: GameResourceDeliveryReceiptStore; + readonly leases: PiProjectWriteLeaseCoordinator; + readonly sleep?: Sleep; + readonly writeOutput?: WriteOutput; + readonly recordTouchedPaths?: RecordTouchedPaths; + readonly acquireBackgroundLease?: (lease: { + id: string; + kind: 'coding-run'; + }) => () => void; +} + +export class GameResourceDeliveryCoordinator { + private readonly flights = new Map>(); + private readonly sleep: Sleep; + private readonly writeOutput: WriteOutput; + private readonly shutdown = new AbortController(); + + constructor(private readonly options: GameResourceDeliveryCoordinatorOptions) { + this.sleep = options.sleep ?? defaultSleep; + this.writeOutput = options.writeOutput ?? defaultWriteOutput; + } + + async generateAndMaterialize(input: { + readonly context: GameResourceDeliveryContext; + readonly request: GameResourceGenerateInput; + readonly onProgress?: (progress: GameResourceDeliveryProgress) => void; + }): Promise { + const operationId = input.context.logicalOperationId; + const existingFlight = this.flights.get(operationId); + if (existingFlight) return await existingFlight; + + const releaseBackground = this.options.acquireBackgroundLease?.({ + id: `game-resource:${operationId}`, + kind: 'coding-run', + }) ?? (() => undefined); + const flight = this.run(input).finally(releaseBackground); + this.flights.set(operationId, flight); + try { + return await flight; + } finally { + if (this.flights.get(operationId) === flight) this.flights.delete(operationId); + } + } + + async resumePending(): Promise { + const receipts = await this.options.receipts.list(); + const pending = receipts.filter((receipt) => receipt.deliveryStatus !== 'saved' + && (receipt.providerStatus === 'succeeded' + || ACTIVE_PROVIDER_STATUSES.has(receipt.providerStatus))); + const results: GameResourceDeliveryResult[] = []; + for (const receipt of pending) { + const existingFlight = this.flights.get(receipt.logicalOperationId); + if (existingFlight) { + try { + results.push(await existingFlight); + } catch { + // One unavailable reconciliation must not block other persisted deliveries. + } + continue; + } + const releaseBackground = this.options.acquireBackgroundLease?.({ + id: `game-resource:${receipt.logicalOperationId}`, + kind: 'coding-run', + }) ?? (() => undefined); + const flight = this.continueReceipt(receipt).finally(releaseBackground); + this.flights.set(receipt.logicalOperationId, flight); + try { + results.push(await flight); + } catch { + // Keep the receipt for a later retry and continue with independent executions. + } finally { + if (this.flights.get(receipt.logicalOperationId) === flight) { + this.flights.delete(receipt.logicalOperationId); + } + } + } + return results; + } + + dispose(): void { + this.shutdown.abort(); + } + + private async run(input: { + readonly context: GameResourceDeliveryContext; + readonly request: GameResourceGenerateInput; + readonly onProgress?: (progress: GameResourceDeliveryProgress) => void; + }): Promise { + const persisted = await this.options.receipts.get(input.context.logicalOperationId); + if (persisted) return await this.continueReceipt(persisted, input.onProgress); + + const generated = await this.options.client.generate(input.request); + let receipt = this.receiptFromGeneration(input.context, generated); + await this.options.receipts.put(receipt); + this.report(input.onProgress, 'submitted', receipt); + return await this.continueReceipt(receipt, input.onProgress); + } + + private async continueReceipt( + initial: DeliveryReceipt, + onProgress?: (progress: GameResourceDeliveryProgress) => void, + ): Promise { + let receipt = initial; + if (receipt.deliveryStatus === 'saved') return resultFrom(receipt); + + while (ACTIVE_PROVIDER_STATUSES.has(receipt.providerStatus)) { + this.report(onProgress, 'generating', receipt); + await this.sleep(receipt.pollIntervalSeconds * 1_000, this.shutdown.signal); + const generation = await this.options.client.get(receipt.executionId); + receipt = { + ...receipt, + providerStatus: generation.status, + outputCount: generation.outputCount, + pollIntervalSeconds: generation.pollIntervalSeconds ?? receipt.pollIntervalSeconds, + billing: generation.billing, + ...(generation.errorCode ? { errorCode: generation.errorCode } : {}), + updatedAt: new Date().toISOString(), + }; + await this.options.receipts.put(receipt); + } + + if (receipt.providerStatus !== 'succeeded') { + const terminal = { + ...receipt, + deliveryStatus: 'not_started' as const, + updatedAt: new Date().toISOString(), + }; + await this.options.receipts.put(terminal); + return resultFrom(terminal); + } + return await this.materializeWithRetry(receipt, onProgress); + } + + private async materializeWithRetry( + receipt: DeliveryReceipt, + onProgress?: (progress: GameResourceDeliveryProgress) => void, + ): Promise { + let result = await this.materialize(receipt, onProgress); + for (let attempt = 1; + attempt < MAX_LOCAL_DELIVERY_ATTEMPTS && result.deliveryStatus === 'delivery_failed'; + attempt += 1) { + await this.sleep(LOCAL_DELIVERY_RETRY_DELAY_MS, this.shutdown.signal); + const persisted = await this.options.receipts.get(receipt.logicalOperationId); + result = await this.materialize(persisted ?? receipt, onProgress); + } + return result; + } + + private async materialize( + receipt: DeliveryReceipt, + onProgress?: (progress: GameResourceDeliveryProgress) => void, + ): Promise { + const saving: DeliveryReceipt = { + ...receipt, + deliveryStatus: 'saving', + files: [], + updatedAt: new Date().toISOString(), + }; + await this.options.receipts.put(saving); + this.report(onProgress, 'saving', saving); + + let downloads: Awaited>[]; + try { + downloads = await Promise.all(Array.from( + { length: receipt.outputCount }, + async (_, index) => await this.options.client.download(receipt.executionId, index), + )); + } catch { + return await this.failDelivery(receipt); + } + const files = downloads.map((download, index) => ({ + path: `assets/generated/game-resource/${receipt.executionId}/output-${index + 1}${extensionFor(download.fileName, download.contentType)}`, + bytes: download.bytes.byteLength, + content: download.bytes, + })); + + const lease = await this.options.leases.acquire( + receipt.localProjectId, + `game-resource:${receipt.logicalOperationId}`, + ); + try { + for (const file of files) { + const target = path.join(receipt.projectPath, ...file.path.split('/')); + await mkdir(path.dirname(target), { recursive: true }); + try { + await this.writeOutput(target, file.content); + } catch (error) { + if (!isRecord(error) || error.code !== 'EEXIST') throw error; + const existing = await readFile(target); + if (!existing.equals(Buffer.from(file.content))) throw error; + } + } + const saved: DeliveryReceipt = { + ...receipt, + deliveryStatus: 'saved', + files: files.map(({ path: filePath, bytes }) => ({ path: filePath, bytes })), + errorCode: undefined, + updatedAt: new Date().toISOString(), + }; + await this.options.receipts.put(saved); + try { + await this.options.recordTouchedPaths?.( + receipt.conversationId, + receipt.runId, + saved.files.map((file) => file.path), + ); + } catch { + // The original run may no longer exist after an app restart; saved files remain authoritative. + } + this.report(onProgress, 'saved', saved); + return resultFrom(saved); + } catch { + return await this.failDelivery(receipt); + } finally { + lease.release(); + } + } + + private async failDelivery(receipt: DeliveryReceipt): Promise { + const failed: DeliveryReceipt = { + ...receipt, + deliveryStatus: 'delivery_failed', + files: [], + errorCode: 'game_resource_delivery_failed', + updatedAt: new Date().toISOString(), + }; + await this.options.receipts.put(failed); + return resultFrom(failed); + } + + private receiptFromGeneration( + context: GameResourceDeliveryContext, + generation: GameResourceGeneration, + ): DeliveryReceipt { + return { + logicalOperationId: context.logicalOperationId, + executionId: generation.executionId, + conversationId: context.conversationId, + runId: context.runId, + localProjectId: context.localProjectId, + durableProjectId: context.durableProjectId, + projectPath: context.projectPath, + providerStatus: generation.status, + outputCount: generation.outputCount, + pollIntervalSeconds: generation.pollIntervalSeconds ?? 3, + billing: generation.billing, + deliveryStatus: generation.status === 'succeeded' ? 'waiting' : 'not_started', + files: [], + ...(generation.errorCode ? { errorCode: generation.errorCode } : {}), + updatedAt: new Date().toISOString(), + }; + } + + private report( + callback: ((progress: GameResourceDeliveryProgress) => void) | undefined, + phase: GameResourceDeliveryProgress['phase'], + receipt: DeliveryReceipt, + ): void { + try { + callback?.({ phase, ...resultFrom(receipt) }); + } catch { + // Progress is observational and must not alter provider or delivery state. + } + } +} diff --git a/resources/coding-plugins/game-resource/com.makelore/capability.json b/resources/coding-plugins/game-resource/com.makelore/capability.json index c438a90..e07a0b6 100644 --- a/resources/coding-plugins/game-resource/com.makelore/capability.json +++ b/resources/coding-plugins/game-resource/com.makelore/capability.json @@ -54,7 +54,7 @@ { "name": "game_resource_generate", "label": "Generate game resource", - "description": "Submit one hosted pixel-art or HD game-resource generation job.", + "description": "Generate one hosted pixel-art or HD game resource and automatically save every output into the current project.", "capabilityId": "game-resource.generate", "operation": "generate", "roles": ["parent"], @@ -89,93 +89,30 @@ "outputSchema": { "type": "object", "additionalProperties": false, - "required": ["executionId", "status", "outputCount"], + "required": ["executionId", "providerStatus", "deliveryStatus", "outputCount", "files"], "properties": { "executionId": {"type": "string", "minLength": 1, "maxLength": 36}, - "status": {"type": "string", "maxLength": 32}, + "providerStatus": {"type": "string", "minLength": 1, "maxLength": 32}, + "deliveryStatus": {"type": "string", "minLength": 1, "maxLength": 32}, "outputCount": {"type": "integer", "minimum": 0, "maximum": 100}, - "pollIntervalSeconds": {"type": "integer", "minimum": 1, "maximum": 300}, - "errorCode": {"type": "string", "minLength": 1, "maxLength": 128} - } - } - }, - { - "name": "game_resource_status", - "label": "Game resource status", - "description": "Read and refresh one hosted game-resource generation job.", - "capabilityId": "game-resource.generate", - "operation": "status", - "roles": ["parent"], - "mutation": "read", - "projectWriteLease": false, - "permissions": ["hosted.game-resource.status"], - "executionMode": "synchronous", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "required": ["executionId"], - "properties": { - "executionId": {"type": "string", "minLength": 1, "maxLength": 36} - } - }, - "outputSchema": { - "type": "object", - "additionalProperties": false, - "required": ["executionId", "status", "outputCount", "generationBilling"], - "properties": { - "executionId": {"type": "string", "minLength": 1, "maxLength": 36}, - "status": {"type": "string", "minLength": 1, "maxLength": 32}, - "outputCount": {"type": "integer", "minimum": 0, "maximum": 100}, - "pollIntervalSeconds": {"type": "integer", "minimum": 1, "maximum": 300}, + "phase": {"type": "string", "minLength": 1, "maxLength": 32}, "errorCode": {"type": "string", "minLength": 1, "maxLength": 128}, - "generationBilling": { - "type": "object", - "additionalProperties": false, - "required": ["mode", "status", "reserved_points", "usage_amount", "unit"], - "properties": { - "mode": {"type": "string", "minLength": 1, "maxLength": 32}, - "status": {"type": "string", "maxLength": 32}, - "reserved_points": {"type": "string", "maxLength": 32}, - "actual_points": {"type": "string", "maxLength": 32}, - "usage_amount": {"type": "integer", "minimum": 0, "maximum": 1}, - "unit": {"type": "string", "minLength": 1, "maxLength": 32} + "files": { + "type": "array", + "maxItems": 100, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "bytes"], + "properties": { + "path": {"type": "string", "minLength": 1, "maxLength": 1024}, + "bytes": {"type": "integer", "minimum": 1, "maximum": 33554432} + } } } } } }, - { - "name": "game_resource_save_output", - "label": "Save generated game resource", - "description": "Save one completed hosted output into a new file inside the current project.", - "capabilityId": "game-resource.library", - "operation": "save_output", - "roles": ["parent"], - "mutation": "write", - "projectWriteLease": true, - "permissions": ["hosted.game-resource.save-output"], - "executionMode": "synchronous", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "required": ["executionId", "relativePath", "confirmed"], - "properties": { - "executionId": {"type": "string", "minLength": 1, "maxLength": 36}, - "outputIndex": {"type": "integer", "minimum": 0, "maximum": 99}, - "relativePath": {"type": "string", "minLength": 1, "maxLength": 1024}, - "confirmed": {"type": "boolean"} - } - }, - "outputSchema": { - "type": "object", - "additionalProperties": false, - "required": ["savedPath", "bytes"], - "properties": { - "savedPath": {"type": "string", "minLength": 1, "maxLength": 1024}, - "bytes": {"type": "integer", "minimum": 1, "maximum": 33554432} - } - } - }, { "name": "game_resource_cancel", "label": "Cancel game resource", diff --git a/resources/coding-plugins/game-resource/skills/game-resource/SKILL.md b/resources/coding-plugins/game-resource/skills/game-resource/SKILL.md index 767ed63..09b0e17 100644 --- a/resources/coding-plugins/game-resource/skills/game-resource/SKILL.md +++ b/resources/coding-plugins/game-resource/skills/game-resource/SKILL.md @@ -12,15 +12,18 @@ Meowa 凭据、Provider URL、Provider job ID,也不要直接访问第三方 1. 先确认资源用途、像素或高清类型、画面要求、比例和实际需要的参考图;用 `game_resource_templates` 获取当前可用模板,不要猜模板名。 -2. 明确告诉用户生成按 Token Point 用量计费,并等待用户确认本次具体生成请求。 -3. 确认后只调用一次 `game_resource_generate`。结果不确定、超时或 - `pending_review` 时不得换 logical operation 重试;报告状态并使用原 - `executionId` 查询。 -4. 用 `game_resource_status` 查询同一个 execution,直到 succeeded、failed、 - cancelled 或 pending_review。轮询间隔遵守返回的 `pollIntervalSeconds`。 -5. 只有用户明确要求取消时才调用 `game_resource_cancel`。 -6. succeeded 后,只有用户明确给出项目内目标路径并确认保存时,才调用 - `game_resource_save_output`。该工具只创建新文件,不覆盖现有文件。 +2. 明确告诉用户生成按 Token Point 用量计费;本次确认同时授权生成完成后自动 + 保存全部输出到当前项目,不再另行询问路径或保存确认。 +3. 确认后只调用一次 `game_resource_generate`。该工具会在 MakeLore 内部提交、 + 轮询、下载并把全部输出自动保存到当前项目。不要自行轮询,也不要为同一请求 + 再调用生成工具。 +4. 只有用户明确要求取消,且已有 execution ID 时,才调用 + `game_resource_cancel`。 +5. `deliveryStatus=saved` 时,向用户报告工具返回的项目相对路径。 + `deliveryStatus=delivery_failed` 时,说明 Provider 可能已成功且计费可能已完成, + MakeLore 会从本地收据继续交付;不得重新生成或要求用户再次确认。 +6. `pending_review` 或 `submission_unknown` 时只报告现状,不得换 logical operation + 重试,也不得声称已经保存。 ## 浏览与审查 @@ -30,6 +33,7 @@ Provider 地址或通过生成工具上传未获用户同意的文件。 ## 完成标准 -报告必须区分提交、Provider 状态和 Token Point receipt。只引用工具实际返回的 -execution、status、outputCount 与 billing;不要猜测余额、价格、Provider 成本或 -输出路径。没有 settled receipt 时不得声称计费已最终完成。 +报告必须区分 Provider 状态、自动交付状态和 Token Point receipt。只引用工具实际 +返回的 execution、providerStatus、deliveryStatus、files 与 billing;不要猜测余额、 +价格、Provider 成本或输出路径。没有 `deliveryStatus=saved` 时不得声称文件已经写入 +项目;没有 settled receipt 时不得声称计费已最终完成。 diff --git a/shared/coding-plugins.ts b/shared/coding-plugins.ts index 6df0a59..d7c89ea 100644 --- a/shared/coding-plugins.ts +++ b/shared/coding-plugins.ts @@ -106,6 +106,17 @@ export const GAME_RESOURCE_BUNDLED_RELEASE_ID = '00000000-0000-4000-8000-0000000 export const PROJECT_SCAFFOLD_PLUGIN_ID = 'makelore.project-scaffold' as const; export const PROJECT_SCAFFOLD_BUNDLED_RELEASE_ID = '00000000-0000-4000-8000-000000000303' as const; +/** + * Project-wide Plugins materialize all of their Skills for every parent Agent + * once the Plugin is enabled for that project. They never require a separate + * Agent Skill assignment. + */ +export function isProjectWideCodingPluginId(pluginId: string): boolean { + return pluginId === DATA_SERVICE_PLUGIN_ID + || pluginId === GAME_RESOURCE_PLUGIN_ID + || pluginId === PROJECT_SCAFFOLD_PLUGIN_ID; +} + export const CODE_OWNED_OPTIONAL_BUNDLED_RELEASES = Object.freeze({ [GAME_RESOURCE_PLUGIN_ID]: Object.freeze({ releaseId: GAME_RESOURCE_BUNDLED_RELEASE_ID, diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index e9df849..610f501 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -34,9 +34,11 @@ import { subscribeCodingProjectCreationRequest, subscribeCodingProjectOpenRequest, } 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'; +import { fetchWorksTokenPointBalance, type WorksTokenPointBalance } from '@/lib/works-square'; +import { + formatWorksTokenPointValue, + isWorksTokenPointBalanceExhausted, +} from '@/lib/works-square-token-points'; import { openWorksSquareSubscriptionUpgrade } from '@/lib/subscription-upgrade'; import { cn } from '@/lib/utils'; import { useCurrentUserProfile } from '@/hooks/use-current-user-profile'; @@ -60,10 +62,9 @@ type OpenDialogResult = { filePaths: string[]; }; -type TokenUsageState = { +type TokenPointState = { status: 'idle' | 'loading' | 'loaded' | 'error'; - usage: WorksTokenUsage | null; - error?: string; + balance: WorksTokenPointBalance | null; }; type ProjectEntryError = { @@ -79,12 +80,6 @@ function getFolderName(pathValue: string): string { return withoutTrailingSeparators.split(/[\\/]/u).filter(Boolean).at(-1) ?? trimmed; } -function formatRemainingPercent(value: number | null | undefined): string { - if (typeof value !== 'number' || !Number.isFinite(value)) return '不限'; - const clamped = Math.min(100, Math.max(0, value)); - return `${Math.round(clamped)}%`; -} - function padTimePart(value: number): string { return String(value).padStart(2, '0'); } @@ -101,32 +96,59 @@ function getRefreshTimeLabel(value: string | null | undefined): string | null { return formatted ? `刷新时间 ${formatted}` : null; } -function isTokenUsageExhausted(state: TokenUsageState): boolean { - if (state.status !== 'loaded' || !state.usage) return false; - return isWorksTokenUsageExhausted(state.usage); +function isTokenPointBalanceExhausted(state: TokenPointState): boolean { + if (state.status !== 'loaded' || !state.balance) return false; + return isWorksTokenPointBalanceExhausted(state.balance); } -function getSubscriptionPlanLabel(user: AuthUser | null, state: TokenUsageState): string { +function getSubscriptionPlanLabel(user: AuthUser | null, state: TokenPointState): string { if (!user) return '未登录'; - const planName = state.usage?.plan_name?.trim(); + if (state.balance && !state.balance.can_manage_membership) { + return state.balance.family_shared ? '共享会员' : '青少年账户'; + } + const planName = state.balance?.plan_name?.trim(); if (planName) return planName; - const planCode = state.usage?.plan_code?.trim(); + const planCode = state.balance?.plan_code?.trim(); if (planCode) return planCode; return '会员'; } -function isFreeSubscriptionPlan(state: TokenUsageState): boolean { - const planCode = state.usage?.plan_code?.trim().toLowerCase(); - const planName = state.usage?.plan_name?.trim().toLowerCase(); - return planCode === 'free' || planName === 'free'; +function isExperienceSubscriptionPlan(state: TokenPointState): boolean { + const planCode = state.balance?.plan_code?.trim().toLowerCase(); + const planName = state.balance?.plan_name?.trim().toLowerCase(); + return planCode === 'experience' || planName === '体验'; } -function getSubscriptionMembershipLabel(user: AuthUser | null, state: TokenUsageState): string { +function getSubscriptionMembershipLabel(user: AuthUser | null, state: TokenPointState): string { const levelLabel = getSubscriptionPlanLabel(user, state); - if (!user || isFreeSubscriptionPlan(state) || levelLabel === '会员') return levelLabel; + if ( + !user + || isExperienceSubscriptionPlan(state) + || levelLabel === '会员' + || levelLabel === '共享会员' + || levelLabel === '青少年账户' + ) return levelLabel; return `${levelLabel} 会员`; } +function getWeeklyPointLabel(balance: WorksTokenPointBalance | null): string { + const remaining = formatWorksTokenPointValue(balance?.weekly_remaining); + const allowance = formatWorksTokenPointValue(balance?.weekly_allowance); + if (remaining && allowance) return `${remaining} / ${allowance} 点`; + return remaining ? `${remaining} 点` : '—'; +} + +function getTotalPointLabel(balance: WorksTokenPointBalance | null): string { + const remaining = formatWorksTokenPointValue(balance?.total_remaining); + return remaining ? `${remaining} 点` : '—'; +} + +function getCoarsePointLabel(balance: WorksTokenPointBalance | null): string { + if (balance?.shared_available === true) return '可用'; + if (balance?.shared_available === false) return '已用尽'; + return balance?.family_shared ? '由家庭管理员管理' : '由家长管理'; +} + type SidebarProps = { workspaceLayout?: boolean; sidebarPeekOpen?: boolean; @@ -160,7 +182,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi const [accountMenuOpen, setAccountMenuOpen] = useState(false); const [profileDialogOpen, setProfileDialogOpen] = useState(false); const [usageDrawerOpen, setUsageDrawerOpen] = useState(false); - const [tokenUsageState, setTokenUsageState] = useState({ status: 'idle', usage: null }); + const [tokenPointState, setTokenPointState] = useState({ status: 'idle', balance: null }); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [newProjectName, setNewProjectName] = useState(''); const [newProjectSelectedPath, setNewProjectSelectedPath] = useState(''); @@ -174,7 +196,7 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi const [robotSelectedAgentId, setRobotSelectedAgentId] = useState(() => readAiHardwareAgentId()); const accountButtonRef = useRef(null); const accountMenuRef = useRef(null); - const tokenUsageRequestIdRef = useRef(0); + const tokenPointRequestIdRef = useRef(0); const navigate = useNavigate(); const location = useLocation(); const activeModule = getAiModuleForPath(location.pathname); @@ -188,11 +210,14 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi const accountInitial = getAccountInitial(accountName); const accountAvatarUrl = userProfile?.avatarUrl ?? null; const settingsLabel = t('sidebar.settings', '设置'); - const memberLabel = getSubscriptionPlanLabel(authUser, tokenUsageState); - const membershipLabel = getSubscriptionMembershipLabel(authUser, tokenUsageState); - const tokenUsageExhausted = isTokenUsageExhausted(tokenUsageState); - const fiveHourRefreshLabel = getRefreshTimeLabel(tokenUsageState.usage?.five_hour_refresh_at); - const weeklyRefreshLabel = getRefreshTimeLabel(tokenUsageState.usage?.weekly_refresh_at); + const memberLabel = getSubscriptionPlanLabel(authUser, tokenPointState); + const membershipLabel = getSubscriptionMembershipLabel(authUser, tokenPointState); + const tokenPointBalanceExhausted = isTokenPointBalanceExhausted(tokenPointState); + const tokenPointBalance = tokenPointState.balance; + const tokenPointsCoarse = Boolean( + tokenPointBalance && !tokenPointBalance.can_manage_membership, + ); + const nextRefreshLabel = getRefreshTimeLabel(tokenPointBalance?.next_refresh_at); const activeRobotAgentId = isRobotModule ? robotSelectedAgentId : null; const openRobotRoute = useCallback((agentId: string | null = null, openCreate = false) => { @@ -303,52 +328,48 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi } }, [accountMenuOpen]); - const refreshTokenUsage = useCallback(() => { + const refreshTokenPoints = useCallback(() => { if (!authUser) { - tokenUsageRequestIdRef.current += 1; - setTokenUsageState({ status: 'idle', usage: null }); + tokenPointRequestIdRef.current += 1; + setTokenPointState({ status: 'idle', balance: null }); return; } - const requestId = tokenUsageRequestIdRef.current + 1; - tokenUsageRequestIdRef.current = requestId; - setTokenUsageState((current) => ({ status: 'loading', usage: current.usage })); + const requestId = tokenPointRequestIdRef.current + 1; + tokenPointRequestIdRef.current = requestId; + setTokenPointState((current) => ({ status: 'loading', balance: current.balance })); void getValidAccessToken() .then((accessToken) => { if (!accessToken) { throw new Error('请先登录后再查看用量'); } - return fetchWorksTokenUsage(accessToken); + return fetchWorksTokenPointBalance(accessToken); }) - .then((usage) => { - if (tokenUsageRequestIdRef.current === requestId) { - setTokenUsageState({ status: 'loaded', usage }); + .then((balance) => { + if (tokenPointRequestIdRef.current === requestId) { + setTokenPointState({ status: 'loaded', balance }); } }) - .catch((error) => { - if (tokenUsageRequestIdRef.current === requestId) { - setTokenUsageState({ - status: 'error', - usage: null, - error: error instanceof Error ? error.message : '暂时无法获取套餐额度', - }); + .catch(() => { + if (tokenPointRequestIdRef.current === requestId) { + setTokenPointState({ status: 'error', balance: null }); } }); }, [authUser, getValidAccessToken]); useEffect(() => { - refreshTokenUsage(); - }, [refreshTokenUsage]); + refreshTokenPoints(); + }, [refreshTokenPoints]); useEffect(() => { if (!authUser) return undefined; const handleFocus = () => { - refreshTokenUsage(); + refreshTokenPoints(); }; const handleVisibilityChange = () => { if (document.visibilityState === 'visible') { - refreshTokenUsage(); + refreshTokenPoints(); } }; @@ -358,33 +379,13 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi window.removeEventListener('focus', handleFocus); document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, [authUser, refreshTokenUsage]); + }, [authUser, refreshTokenPoints]); useEffect(() => { if (authUser && accountMenuOpen) { - refreshTokenUsage(); + refreshTokenPoints(); } - }, [accountMenuOpen, authUser, refreshTokenUsage]); - - useEffect(() => { - if (!authUser) return undefined; - const retryTimers = new Set(); - const handleTokenUsageStale = () => { - refreshTokenUsage(); - const retryTimer = window.setTimeout(() => { - retryTimers.delete(retryTimer); - refreshTokenUsage(); - }, 1500); - retryTimers.add(retryTimer); - }; - window.addEventListener(WORKS_SQUARE_TOKEN_USAGE_STALE_EVENT, handleTokenUsageStale); - return () => { - window.removeEventListener(WORKS_SQUARE_TOKEN_USAGE_STALE_EVENT, handleTokenUsageStale); - for (const retryTimer of retryTimers) { - window.clearTimeout(retryTimer); - } - }; - }, [authUser, refreshTokenUsage]); + }, [accountMenuOpen, authUser, refreshTokenPoints]); const openCreateProject = useCallback(() => { @@ -1027,43 +1028,80 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi 订阅等级 {memberLabel} -
- 5 小时 - - {formatRemainingPercent(tokenUsageState.usage?.five_hour_remaining_percent)} - -
-
- 1 周 - - {formatRemainingPercent(tokenUsageState.usage?.weekly_remaining_percent)} - -
- {fiveHourRefreshLabel ? ( -
- {fiveHourRefreshLabel} -
- ) : null} - {weeklyRefreshLabel ? ( -
- {weeklyRefreshLabel} -
- ) : null} - {tokenUsageExhausted ? ( -
-

当前额度已用尽,升级订阅后可继续使用。

+ {tokenPointState.status === 'error' ? ( +
+

词元点数暂时无法获取。

) : null} + {(tokenPointState.status === 'idle' || tokenPointState.status === 'loading') && !tokenPointBalance ? ( +
+ {authUser ? '正在加载词元点数…' : '登录后查看词元点数'} +
+ ) : null} + {tokenPointBalance && tokenPointsCoarse ? ( + <> +
+ {tokenPointBalance.family_shared ? '共享额度' : '词元点数'} + {getCoarsePointLabel(tokenPointBalance)} +
+
+ {tokenPointBalance.family_shared + ? '具体词元点数由家庭管理员管理' + : '具体词元点数由家长管理'} +
+ + ) : null} + {tokenPointBalance && !tokenPointsCoarse ? ( + <> +
+ 本周剩余 + + {getWeeklyPointLabel(tokenPointBalance)} + +
+
+ 总可用 + + {getTotalPointLabel(tokenPointBalance)} + +
+ + ) : null} + {tokenPointBalance && !tokenPointsCoarse && nextRefreshLabel ? ( +
+ {nextRefreshLabel} +
+ ) : null} + {tokenPointBalanceExhausted ? ( +
+

+ {tokenPointsCoarse + ? tokenPointBalance?.family_shared + ? '家庭共享额度已用尽,请联系家庭管理员。' + : '词元点数已用尽,请联系家长。' + : '当前词元点数已用尽,升级订阅后可继续使用。'} +

+ {!tokenPointsCoarse && tokenPointBalance?.upgrade_action === 'self_service' ? ( + + ) : null} +
+ ) : null} +
+ +
+ + + + setAddress(event.target.value)} + aria-label="网页地址" + className="h-7 min-w-0 flex-1 rounded-lg border border-border/90 bg-surface-input px-2.5 text-[11px] font-medium outline-none focus:border-brand/40 focus:ring-2 focus:ring-brand/15" + placeholder="输入 URL" + spellCheck={false} + /> +
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+ {!browserActive ? ( +
+ + +

开始浏览

+

输入 URL,或等待智能体打开调试页面

+
+ ) : null} +
+ +
+
+
+ {(['console', 'network'] as const).map((tab) => ( + + ))} +
+ +
+ + {diagnosticsOpen ? ( +
+ {activeTab === 'console' ? ( +
+ {diagnostics.console.length === 0 ? ( +

暂无 Console 记录

+ ) : diagnostics.console.map((entry) => ( +
+ {formatTime(entry.timestamp)} + {entry.level} + {entry.text || '(empty)'} +
+ ))} +
+ ) : ( +
+ {diagnostics.network.length === 0 ? ( +

暂无 Network 请求

+ ) : ( + + + + + + + + + + + {diagnostics.network.map((entry) => ( + + + + + + + ))} + +
方法状态地址耗时
{entry.method} + {entry.errorText ? '失败' : entry.status ?? '…'} + {entry.url} + {entry.durationMs === undefined ? '…' : `${Math.round(entry.durationMs)}ms`} +
+ )} +
+ )} +
+ ) : null} +
+ + ); +} diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index 2258e94..ce3533e 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -39,6 +39,7 @@ import type { CodingProjectSummary, } from '@/types/coding-project'; import { CodingComposer } from './CodingComposer'; +import { AgentBrowserPanel } from './AgentBrowserPanel'; import { CodingChangesSummary } from './CodingChangesSummary'; import { CodingConversationSidebar } from './CodingConversationSidebar'; import { CodingConversationHeader } from './CodingConversationHeader'; @@ -168,6 +169,7 @@ export function CodingChatPanel({ const [attachmentsByDraftKey, setAttachmentsByDraftKey] = useState< Record >({}); + const [agentBrowserOpen, setAgentBrowserOpen] = useState(false); const appliedNavigationDraftRef = useRef(null); const automaticCreationKeyRef = useRef(null); const selectedConversationContextRef = useRef(null); @@ -262,6 +264,10 @@ export function CodingChatPanel({ void loadWorkspace().catch(() => undefined); }, [loadWorkspace]); + useEffect(() => { + setAgentBrowserOpen(false); + }, [activeProject?.id]); + useEffect(() => () => disconnectEvents(), [disconnectEvents]); useEffect(() => subscribeHostEvent('lifecycle:sleep', () => { @@ -721,6 +727,9 @@ export function CodingChatPanel({ onRecover={async () => { if (targetConversationId) await recoverConversation(targetConversationId); }} + browserOpen={agentBrowserOpen} + browserAvailable={Boolean(activeProject)} + onToggleBrowser={() => setAgentBrowserOpen((current) => !current)} /> {(workspaceError || conversationMetadataError || connectionError) && ( @@ -859,6 +868,12 @@ export function CodingChatPanel({ }} /> + + ); } diff --git a/src/pages/Chat/CodingConversationHeader.tsx b/src/pages/Chat/CodingConversationHeader.tsx index 522d399..17af72d 100644 --- a/src/pages/Chat/CodingConversationHeader.tsx +++ b/src/pages/Chat/CodingConversationHeader.tsx @@ -3,6 +3,8 @@ import { createPortal } from 'react-dom'; import { LoaderCircle, Pencil, + PanelRightClose, + PanelRightOpen, RotateCcw, Square, } from 'lucide-react'; @@ -55,12 +57,18 @@ export function CodingConversationHeader({ onRename, onAbort, onRecover, + browserOpen = false, + browserAvailable = false, + onToggleBrowser, }: { conversation: CodingConversationMetadata | null; snapshot: ConversationSnapshot | null; onRename(title: string): Promise; onAbort(): Promise; onRecover(): Promise; + browserOpen?: boolean; + browserAvailable?: boolean; + onToggleBrowser?(): void; }) { const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); const [busyAction, setBusyAction] = useState(null); @@ -121,6 +129,23 @@ export function CodingConversationHeader({ + {onToggleBrowser ? ( + + ) : null} + {running && runStatus !== 'aborting' && (