From 3245524e403a2f674c438874bc6355d3962289ce Mon Sep 17 00:00:00 2001 From: inman Date: Mon, 31 Aug 2026 13:48:45 +0800 Subject: [PATCH 01/30] docs: record verified main push --- .../tasks/20260831-push-main-4f8c2a91.md | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 .project-docs/30-worklog/tasks/20260831-push-main-4f8c2a91.md diff --git a/.project-docs/30-worklog/tasks/20260831-push-main-4f8c2a91.md b/.project-docs/30-worklog/tasks/20260831-push-main-4f8c2a91.md new file mode 100644 index 0000000..01467ba --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260831-push-main-4f8c2a91.md @@ -0,0 +1,82 @@ +# Task: Integrate all local changes and push main + +## Identity + +- Task ID: 20260831-push-main-4f8c2a91 +- Mode: Integration +- Branch: codex/20260831-push-main-4f8c2a91-push-main +- Worktree: /Users/inmanx/Documents/makelore-push-main-4f8c2a91 +- Base commit: 38f85f6b5e4dc4e2c5e5b9f8f4506554cfd578f5 +- Owner: codex +- Status: Completed + +## Scope + +- Audit every local worktree, local branch, uncommitted path and the latest + `origin/main` before pushing. +- Integrate all confirmed current product changes into local `main`, preserve + current architecture decisions, and push the verified result to `origin/main`. +- Keep peer task-owned records read-only and do not treat diagnostic-only task + records as product source changes. + +## Intent And Constraints + +- Work only from this isolated Integration Gate worktree while the occupied + `main` worktree remains owned by another completed review task. +- Do not restore the retired Learning course runtime or OpenMAIC player without + explicit confirmation that ADR-005 is being reversed. +- Fetch before integration and verify the remote ref after any authorized push. +- Preserve the Main-owned Host API, Canvas V2, `.makelore` authority, Pi single + runtime and current four-module light-product boundaries. + +## Outcome + +- Fetched `origin`; remote `main` remains + `62304dc85b3c1069cd656dfacb61ee820e216fa2`, while local `main` is + `38f85f6b5e4dc4e2c5e5b9f8f4506554cfd578f5` and is ahead by three commits. +- Audited all seven local worktrees. Their only uncommitted paths are task-owned + project records; no uncommitted product source exists. +- Found exactly one local product commit not ancestral to `main`: + `b1f51beb13d2efd46ff2c62865f481fe4b52cb4c` on + `codex/learning-player-release-fix`. The clean branch is one commit ahead and + 280 commits behind current `main`. +- The commit changes the retired OpenMAIC Learning Player artifact download, + synchronization, manifest, README contract and packaging tests. Current main + deliberately removed that runtime and those scripts under accepted ADR-005, + so integrating it would reverse current product behavior rather than apply an + ordinary packaging fix. +- The user explicitly chose to exclude `b1f51be`. ADR-005 remains authoritative, + and the obsolete Learning Player branch will not be merged or pushed. +- Fast-forward pushed local `main` from remote + `62304dc85b3c1069cd656dfacb61ee820e216fa2` to verified product tip + `38f85f6b5e4dc4e2c5e5b9f8f4506554cfd578f5`. +- Queried the remote ref after the push and fetched it again; local `main` and + `origin/main` now resolve to the same object with divergence `0 0`. +- Peer diagnostic/review task records remain in their owning worktrees and were + not promoted as product source. No remote branch other than `main` changed. + +## Verification + +- `git fetch --prune origin`: passed. +- Worktree and branch audit: seven worktrees checked; one unmerged product commit + identified. +- Inspected the complete five-file patch for `b1f51be` and compared it with the + current Learning catalog boundary, ADR-005 and integrated current state. +- `git merge-base --is-ancestor origin/main main` before push: passed. +- `git diff-tree --check origin/main main` before push: passed. +- The pushed product tree is the exact immutable tree already verified by task + `20260831-merge-upstream-main-7c3a91f2`: typecheck passed; 1,726 unit tests + passed with 3 staged-runtime skips; lint passed with zero errors; Vite build + passed; focused Electron E2E passed 13/13. No product file changed after that + verification, so the suites were not redundantly rerun for the network push. +- Remote verification: `ls-remote` and a fresh fetch both returned + `38f85f6b5e4dc4e2c5e5b9f8f4506554cfd578f5`; divergence is `0 0`. + +## Follow-ups + +- None required for this push. The excluded Learning Player branch remains local + historical state and must not be merged unless ADR-005 is explicitly reversed. + +## Promotion Candidates + +- None recorded. From 3c797d69f7aa31ad345c791ccc72244dffd9d9ef Mon Sep 17 00:00:00 2001 From: inman Date: Mon, 31 Aug 2026 13:56:44 +0800 Subject: [PATCH 02/30] docs: record running development environment --- .../tasks/20260831-start-dev-91c4e2a7.md | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .project-docs/30-worklog/tasks/20260831-start-dev-91c4e2a7.md diff --git a/.project-docs/30-worklog/tasks/20260831-start-dev-91c4e2a7.md b/.project-docs/30-worklog/tasks/20260831-start-dev-91c4e2a7.md new file mode 100644 index 0000000..14b3d09 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260831-start-dev-91c4e2a7.md @@ -0,0 +1,62 @@ +# Task: Start Makelore development environment + +## Identity + +- Task ID: 20260831-start-dev-91c4e2a7 +- Mode: Feature +- Branch: codex/20260831-start-dev-91c4e2a7-start-dev +- Worktree: /Users/inmanx/Documents/makelore-start-dev-91c4e2a7 +- Base commit: 38f85f6b5e4dc4e2c5e5b9f8f4506554cfd578f5 +- Owner: codex +- Status: Running + +## Scope + +- Start the pushed `main` product in an isolated worktree without disturbing + the concurrently occupied primary worktree. +- Use the repository-pinned pnpm version and frozen lockfile. +- Keep the Vite, Electron Main, Host API and Electron window processes running + for the user. + +## Intent And Constraints + +- Run the standard `pnpm run dev`; do not enable retired local Canvas semantics + or alter product source. +- Preserve the cloud-only Works Square V2 Canvas contract and current Main-owned + runtime boundaries. +- Retain task ownership while the development process is live so another task + cannot mutate this worktree underneath it. + +## Outcome + +- Created isolated worktree + `/Users/inmanx/Documents/makelore-start-dev-91c4e2a7` from pushed + `main@38f85f6b5e4dc4e2c5e5b9f8f4506554cfd578f5`. +- Installed dependencies with exact `pnpm@10.33.4` and + `pnpm install --frozen-lockfile`. +- Started `pnpm run dev` in persistent terminal session `59768`. +- Vite is listening on IPv6 loopback `http://localhost:5173`; Electron Main's + Host API is listening on `http://127.0.0.1:13210`; Electron PID is `65509`. +- Startup selected the Works Square V2 cloud Canvas contract. The application + remains running. + +## Verification + +- Vite initial compilation completed in 1.679 seconds; Main, Preload and release + utility development bundles built successfully. +- `curl http://localhost:5173/`: HTTP 200. +- `curl http://[::1]:5173/`: HTTP 200. +- `curl http://127.0.0.1:13210/`: expected HTTP 401 authentication challenge. +- `lsof`: Node PID `65494` listens on `[::1]:5173`; Electron PID `65509` + listens on `127.0.0.1:13210`. +- The macOS login-item permission warning is non-fatal and does not affect the + running application. + +## Follow-ups + +- Stop terminal session `59768` when the user asks to stop the development app, + then complete and release this task ownership. + +## Promotion Candidates + +- None recorded. From b3f4166f21bb135f3c8a0c64bca2d2985b994c38 Mon Sep 17 00:00:00 2001 From: inman Date: Mon, 31 Aug 2026 16:30:17 +0800 Subject: [PATCH 03/30] feat: consolidate plugin navigation --- ...-consolidate-plugin-navigation-3d9a6c71.md | 82 +++++++++++++++++++ README.md | 2 +- src/App.tsx | 6 +- src/components/layout/Sidebar.tsx | 34 -------- src/pages/MyPlugins/index.tsx | 14 ++-- src/pages/PluginMarketplace/index.tsx | 44 ++++++++-- src/pages/ProjectConfiguration/index.tsx | 27 ++++-- src/pages/ProjectPlugins/index.tsx | 47 ++++++++--- tests/e2e/plugin-marketplace.spec.ts | 9 +- tests/e2e/project-plugins.spec.ts | 6 +- tests/unit/plugin-marketplace-pages.test.tsx | 15 +++- tests/unit/project-plugins-page.test.tsx | 10 +++ 12 files changed, 227 insertions(+), 69 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260831-consolidate-plugin-navigation-3d9a6c71.md diff --git a/.project-docs/30-worklog/tasks/20260831-consolidate-plugin-navigation-3d9a6c71.md b/.project-docs/30-worklog/tasks/20260831-consolidate-plugin-navigation-3d9a6c71.md new file mode 100644 index 0000000..b652731 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260831-consolidate-plugin-navigation-3d9a6c71.md @@ -0,0 +1,82 @@ +# Task: Consolidate plugin navigation into project configuration + +## Identity + +- Task ID: 20260831-consolidate-plugin-navigation-3d9a6c71 +- Mode: Feature +- Branch: codex/20260831-consolidate-plugin-navigation-3d9a6c71-consolidate-plugin-navigation +- Worktree: /Users/inmanx/Documents/makelore-consolidate-plugin-navigation-3d9a6c71 +- Base commit: 38f85f6b5e4dc4e2c5e5b9f8f4506554cfd578f5 +- Owner: codex +- Status: Completed + +## Scope + +- Replace the separate global “插件中心” and “我的插件” navigation entries with one plugin hub that exposes “发现插件” and “我的插件” as tabs. +- Remove the sidebar “项目插件” entry and embed project plugin enablement/configuration in the existing Project Configuration resource area beside models, skills, and knowledge. +- Preserve account acquisition, device installation, project enablement, Agent skill assignment, runtime authorization, and billing as independent lifecycle states. +- Keep `/my-plugins` and `/project-plugins` as compatibility redirects to the consolidated destinations. +- Update focused unit tests, Electron E2E coverage, and README product-state documentation. + +## Intent And Constraints + +- Reduce navigation duplication without changing Main-owned plugin contracts or `.makelore/project.json` authority. +- Reuse the existing light visual system, Radix tabs/sheets, stores, and Host API clients; add no dependency or alternate backend path. +- Keep plugin discovery/account/device management global, while project enablement and Agent assignment remain project-scoped. +- Do not silently acquire, download, enable, assign, authorize, or bill as a side effect of another action. +- Preserve unavailable/removed plugin IDs and explicit status messaging rather than inventing metadata or hiding retained configuration. + +## Outcome + +- Consolidated the two global plugin destinations into one `/plugin-marketplace` + hub. “发现插件” and “我的插件” are URL-backed tabs, and the sidebar now exposes + only the single “插件中心” entry. +- Removed the project plugin sidebar entry and added “项目插件” beside models, + Skills, and knowledge files in Project Configuration. The resource card reports + enabled/available counts and opens the existing project plugin projection in a + wider configuration sheet. +- Reused the existing Marketplace, Library, device installation, project plugin, + and Agent Skill stores. Enabling a project plugin refreshes the Skill projection, + but does not acquire, download, configure a backend, or assign a Skill. +- Preserved compatibility for saved/deep links: `/my-plugins` redirects to the + “我的插件” tab and `/project-plugins` redirects to the Project Configuration + plugin sheet. My Plugins project actions now target the consolidated routes. +- Kept the standalone view variants available for focused rendering tests while + adding semantic embedded `section` variants for the tab and configuration sheet. +- Updated README product-state copy and focused unit/Electron E2E coverage. + +## Verification + +- Exact `pnpm@10.33.4` was used for repository commands. +- `pnpm run typecheck`: passed. +- Focused Vitest: 2 files / 17 tests passed. +- `pnpm test`: 212 files / 1,728 tests passed; 3 existing conditional tests skipped. +- `pnpm run lint:check`: passed with zero errors and five pre-existing warnings + (`Home/index.tsx` hook dependency and four `Makelore/index.tsx` Fast Refresh warnings). +- `pnpm run build:vite`: passed for Renderer, Electron Main, Preload, and utility + worker; existing dynamic-import and large-chunk warnings remain non-fatal. +- Focused Electron E2E: 3/3 passed across unified plugin discovery/account-device + management and Project Configuration plugin enablement. +- `git diff --check`: passed. + +## Follow-ups + +- Canonical `.project-docs` navigation wording should be promoted only by a later + Integration Gate; no plugin lifecycle, trust, billing, or backend contract changed. + +## Promotion Candidates + +- Target canonical documents: `.project-docs/30-worklog/current-state.md` and + `.project-docs/20-architecture/module-map.md`. +- Proposal: record that Renderer now exposes a single global Plugin Center with + discovery/account-device tabs, while project enablement/configuration is embedded + in Project Configuration beside models, Skills, and knowledge. +- Evidence: this task's source diff, 17 focused unit tests, the 1,728-test full unit + suite, three focused Electron E2E flows, typecheck, lint, and production build. +- Future impact: new navigation, documentation, and E2E work should target + `/plugin-marketplace` and `/project-config?resource=plugins`; the legacy routes are + compatibility redirects only. +- Semantic conflicts: none identified. The existing independent lifecycle and + Main-owned trust boundaries remain unchanged. +- Human confirmation: not required for the factual promotion; the user explicitly + approved this consolidation, but Integration Gate ownership is still required. diff --git a/README.md b/README.md index c30e2fd..db2c9b3 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人 - AI 绘画健壮性:Renderer 为每次命令生成稳定 operation id,网络结果未知时保留原命令供原样重试,不把未知写入当失败或创建第二次生成。Main 负责 Token 刷新、Agent Gateway REST 提交和有界 Run 查询,并将可恢复事件流投影为 Host API SSE;断线后按事件游标续接并重新读取权威 Workspace。Canvas 只使用 Works Square 云端 V2 契约,没有本地语义适配器或降级路径,上游不可用时明确报错。 - AI 绘画项目栏只展示 Workspace,不再在项目下创建独立设计会话。删除时必须完整输入项目名称;删除后项目、Living Form、任务、参考图和生成作品会从账户中隐藏且无法访问,不影响用户已另存到磁盘的副本。删除当前项目后自动打开最近更新的剩余项目,删除最后一个项目后进入空状态。 - AI 学习:主区展示服务端分页项目卡片,详情页用安全 Markdown 渲染 README;原始 HTML 被禁用,Markdown 图片节点直接加载服务端校验后的无凭据 HTTPS URL,包括 SVG 和 Electron 支持的其他图片格式,不经过服务端下载、识别、转码或镜像。下载按钮打开系统保存对话框,Main 不按 `Content-Length`、声明字节数或客户端上限阻断下载,流式校验 SHA-256 与 ZIP 签名后原子保存。客户端不提供课程生成、课程播放器、本地课程库、Agent、ASR 或课堂 runtime。运营管理与接口字段见 [`docs/learning-project-catalog-server-contract.md`](docs/learning-project-catalog-server-contract.md)。 -- 插件市场 Release A:插件中心(Marketplace)提供运营精选的免费 `skill_only` 插件;“免费获取”只写入账号 Library,“下载/更新”才写入本机 Package Store,“启用到项目”和“分配给伙伴”仍是 Project Plugins 中彼此独立的动作。我的插件(My Plugins)展示账号获取状态、本机安装/更新/删除设备包、移除后的 tombstone 和 bounded unavailable reason;Project Plugins 继续只修改项目选择,不会因获取或下载自动启用或分配。 +- 插件市场 Release A:侧栏只保留一个“插件中心”,其中“发现插件”与“我的插件”两个标签分别承载 Marketplace 浏览获取,以及账号 Library、本机安装/更新/删除设备包、移除后的 tombstone 和 bounded unavailable reason。项目插件入口位于项目配置,与模型、Skill 和知识文件并列;“免费获取”只写入账号 Library,“下载/更新”才写入本机 Package Store,“启用到项目”和“分配给伙伴”仍是彼此独立的动作,不会因获取或下载自动推进。 - 插件运行架构:Renderer 只调用 Main-owned Marketplace facade;Main 负责账号、请求 deadline、签名/摘要校验、不可变 Release、current selection 与原子回滚。下一代 Pi worker 使用同一个 effective snapshot,将每个有效 Skill 与已验证 Package Store root 成对传给 resource loader、Extension Host 和 CLI;`skill_only` 不依赖运行时 Policy,也不执行分发包中的任意代码。正式激活仍等待官方 Ed25519 公钥(production key activation HOLD);生产私钥只能来自部署 secret,测试使用注入的临时密钥。 - 提示词博物馆:只陈列经过审核的作品预览、Prompt、分类以及作者/来源/许可证信息,支持搜索、使用场景/风格/主体筛选和详情抽屉;“使用此 Prompt”只把原文带回当前 Canvas 对话草稿,不自动发送、不构成社区。列表和详情数据由服务端提供,客户端不打包数据集;服务端字段契约见 [`docs/prompt-museum-server-contract.md`](docs/prompt-museum-server-contract.md)。 - 视觉系统:单一浅色主题,品牌蓝 `#3A5578`、星火橙 `#F26A3D`、白色画布与低饱和蓝灰层级。 diff --git a/src/App.tsx b/src/App.tsx index b991028..c3967a1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,9 +32,7 @@ const Chat = lazy(() => import('./pages/Chat').then(({ Chat: component }) => ({ const Home = lazy(() => import('./pages/Home').then(({ Home: component }) => ({ default: component }))); const PreviewScene = lazy(() => import('./pages/Makelore').then(({ PreviewScene: component }) => ({ default: component }))); const ProjectConfiguration = lazy(() => import('./pages/ProjectConfiguration').then(({ ProjectConfiguration: component }) => ({ default: component }))); -const ProjectPlugins = lazy(() => import('./pages/ProjectPlugins').then(({ ProjectPlugins: component }) => ({ default: component }))); const PluginMarketplace = lazy(() => import('./pages/PluginMarketplace').then(({ PluginMarketplace: component }) => ({ default: component }))); -const MyPlugins = lazy(() => import('./pages/MyPlugins').then(({ MyPlugins: component }) => ({ default: component }))); const Workbench = lazy(() => import('./pages/Workbench').then(({ Workbench: component }) => ({ default: component }))); const ImageCanvas = lazy(() => import('./pages/ImageCanvas').then(({ ImageCanvas: component }) => ({ default: component }))); const ImagePromptMuseum = lazy(() => import('./pages/ImagePromptMuseum').then(({ ImagePromptMuseum: component }) => ({ default: component }))); @@ -446,9 +444,9 @@ function App() { )} > } /> - } /> + } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index f7502cc..b123837 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -12,8 +12,6 @@ import { FolderKanban, LogOut, Plus, - Puzzle, - Library, Store, Settings as SettingsIcon, UserCircle2, @@ -604,21 +602,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi {!sidebarCollapsed ? 插件中心 : null} -
- {activeProject ? ( - - ) : null} ) : isLearningModule ? ( diff --git a/src/pages/MyPlugins/index.tsx b/src/pages/MyPlugins/index.tsx index 34f35af..5e8c856 100644 --- a/src/pages/MyPlugins/index.tsx +++ b/src/pages/MyPlugins/index.tsx @@ -37,6 +37,7 @@ function actionErrorText(error?: string | null): string | null { } export type MyPluginsViewProps = { + embedded?: boolean; library: MarketplaceLibrarySnapshot | null; installations: Record; state: 'idle' | 'loading' | 'ready' | 'error'; @@ -53,9 +54,10 @@ export type MyPluginsViewProps = { export function MyPluginsView(props: MyPluginsViewProps) { const visibleError = actionErrorText(props.error); + const Root = props.embedded ? 'section' : 'main'; return ( -
-

我的插件

账号插件库与本机下载分开管理。下载不会自动启用到项目,也不会分配给伙伴。

+ +
{props.embedded ?

我的插件

:

我的插件

}

账号插件库与本机下载分开管理。下载不会自动启用到项目,也不会分配给伙伴。

{props.library?.stale ?

正在显示上次可信的插件库;服务恢复前,在线计量或托管能力保持不可用。

: null} {props.state === 'loading' && !props.library ?

正在读取我的插件…

: null} {props.state === 'error' && !props.library ?

无法读取我的插件

{props.error ?? '请稍后重试。'}

: null} @@ -87,17 +89,17 @@ export function MyPluginsView(props: MyPluginsViewProps) { {!removed && plugin.acquisition !== 'system_included' && plugin.betaVersion && installedChannel !== 'beta' ? : null} {!removed && plugin.acquisition !== 'system_included' ? : null} {hasDevicePackage ? : null} - + ; })} -
+ ); } -export function MyPlugins() { +export function MyPlugins({ embedded = false }: { embedded?: boolean } = {}) { const user = useAuthStore((state) => state.user); const accountKey = user?.userId ?? user?.username ?? null; const library = usePluginMarketplaceStore((state) => state.library); @@ -113,5 +115,5 @@ export function MyPlugins() { toast.error(reason instanceof Error ? reason.message : String(reason)); }); const store = pluginMarketplaceStore.getState; - return safe(store().loadLibrary())} onInstall={(id) => safe(store().install(id))} onInstallBeta={(id) => safe(store().installBeta(id))} onUpdate={(id) => safe(store().update(id))} onUninstall={(id) => safe(store().uninstall(id))} onRemove={(id) => safe(store().remove(id))} onReacquire={(id) => safe(store().acquire(id))} />; + return safe(store().loadLibrary())} onInstall={(id) => safe(store().install(id))} onInstallBeta={(id) => safe(store().installBeta(id))} onUpdate={(id) => safe(store().update(id))} onUninstall={(id) => safe(store().uninstall(id))} onRemove={(id) => safe(store().remove(id))} onReacquire={(id) => safe(store().acquire(id))} />; } diff --git a/src/pages/PluginMarketplace/index.tsx b/src/pages/PluginMarketplace/index.tsx index f12130e..07c5be3 100644 --- a/src/pages/PluginMarketplace/index.tsx +++ b/src/pages/PluginMarketplace/index.tsx @@ -1,8 +1,11 @@ import { FormEvent, useEffect, useMemo, useState } from 'react'; import { CheckCircle2, Download, Puzzle, Search, ShieldCheck } from 'lucide-react'; +import { useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { MyPlugins } from '@/pages/MyPlugins'; import type { MarketplaceCatalogPage, MarketplaceCatalogQuery, @@ -20,6 +23,7 @@ const USAGE_COPY: Record = { }; export type PluginMarketplaceViewProps = { + embedded?: boolean; catalog: MarketplaceCatalogPage | null; detail: MarketplacePluginDetail | null; library: MarketplaceLibrarySnapshot | null; @@ -42,12 +46,15 @@ export function PluginMarketplaceView(props: PluginMarketplaceViewProps) { event.preventDefault(); void props.onSearch({ ...(query.trim() ? { query: query.trim() } : {}), ...(category ? { category } : {}), limit: 24 }); }; + const Root = props.embedded ? 'section' : 'main'; return ( -
+
-

插件中心

+ {props.embedded + ?

发现插件

+ :

插件中心

}

浏览 MakeLore 运营发行的插件。免费获取只加入账号插件库,不会自动下载、启用项目或分配给伙伴。

{props.catalog ?

{props.catalog.total} 个插件

: null} @@ -103,11 +110,13 @@ export function PluginMarketplaceView(props: PluginMarketplaceViewProps) { {props.detail ? : null} -
+ ); } export function PluginMarketplace() { + const [searchParams, setSearchParams] = useSearchParams(); + const activeTab = searchParams.get('tab') === 'mine' ? 'mine' : 'discover'; const user = useAuthStore((state) => state.user); const accountKey = user?.userId ?? user?.username ?? null; const catalog = usePluginMarketplaceStore((state) => state.catalog); @@ -119,11 +128,34 @@ export function PluginMarketplace() { const [selectedId, setSelectedId] = useState(null); useEffect(() => { pluginMarketplaceStore.getState().activateAccount(accountKey); - void pluginMarketplaceStore.getState().loadCatalog({ limit: 24 }).catch(() => undefined); + if (activeTab === 'discover') void pluginMarketplaceStore.getState().loadCatalog({ limit: 24 }).catch(() => undefined); if (accountKey) void pluginMarketplaceStore.getState().loadLibrary().catch(() => undefined); - }, [accountKey]); + }, [accountKey, activeTab]); const safe = (operation: Promise) => operation.catch((error) => { toast.error(error instanceof Error ? error.message : String(error)); }); - return safe(pluginMarketplaceStore.getState().loadCatalog(input))} onSelect={(pluginId) => { setSelectedId(pluginId); return safe(pluginMarketplaceStore.getState().loadDetail(pluginId)); }} onAcquire={(pluginId) => safe(pluginMarketplaceStore.getState().acquire(pluginId))} />; + const handleTabChange = (value: string) => { + const next = new URLSearchParams(searchParams); + if (value === 'mine') next.set('tab', 'mine'); + else next.delete('tab'); + setSearchParams(next, { replace: true }); + }; + return
+
+

插件中心

+

在同一处发现插件,并管理账号插件库与本机下载。获取、下载、项目启用和伙伴分配始终是彼此独立的步骤。

+
+ + + 发现插件 + 我的插件 + + + safe(pluginMarketplaceStore.getState().loadCatalog(input))} onSelect={(pluginId) => { setSelectedId(pluginId); return safe(pluginMarketplaceStore.getState().loadDetail(pluginId)); }} onAcquire={(pluginId) => safe(pluginMarketplaceStore.getState().acquire(pluginId))} /> + + + + + +
; } diff --git a/src/pages/ProjectConfiguration/index.tsx b/src/pages/ProjectConfiguration/index.tsx index 6dcbdcc..4dbd8ad 100644 --- a/src/pages/ProjectConfiguration/index.tsx +++ b/src/pages/ProjectConfiguration/index.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { ArrowLeft, Blocks, Check, Cpu, FileText, Files, FolderOpen, Plus, Trash2, Upload, X } from 'lucide-react'; +import { ArrowLeft, Blocks, Check, Cpu, FileText, Files, FolderOpen, Plus, Puzzle, Trash2, Upload, X } from 'lucide-react'; import { toast } from 'sonner'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -8,6 +8,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'; import { AgentCreationDialog, type AgentCreationInput } from '@/components/coding/AgentCreationDialog'; import { ProjectPublishAction } from '@/components/works/ProjectPublishAction'; +import { ProjectPlugins } from '@/pages/ProjectPlugins'; import { buildCodingModelOptions, parseCodingModelKey, type CodingModelOption } from '@/lib/coding-model-options'; import { getCodingSkills } from '@/lib/coding-product-tools'; import { abortCodingConversation } from '@/lib/coding-conversations'; @@ -16,6 +17,7 @@ import { getSkillDisplayInfo } from '@/lib/skill-display'; import { getAgentAvatarSrc } from '@/lib/agent-avatars'; import { cn } from '@/lib/utils'; import { codingConversationStore } from '@/stores/coding-conversations'; +import { codingPluginsStore, useCodingPluginsStore } from '@/stores/coding-plugins'; import { useCodingWorkspaceStore } from '@/stores/coding-workspace'; import { useProjectConfigStore } from '@/stores/project-config'; import { useProviderStore } from '@/stores/providers'; @@ -23,7 +25,7 @@ import type { CodingProjectAgent, CodingProjectConfig } from '@/types/coding-pro import type { ProjectIdentityChoice } from '../../../shared/coding-project-contracts'; const EMPTY_FILES: string[] = []; -type DrawerMode = 'models' | 'knowledge' | 'skills' | null; +type DrawerMode = 'models' | 'knowledge' | 'skills' | 'plugins' | null; type ProjectIdentityKind = ProjectIdentityChoice['kind']; type SkillStructureEntry = { path: string; type: 'file' | 'directory' }; type SkillInfo = { @@ -141,6 +143,8 @@ export function ProjectConfiguration() { const providerAccounts = useProviderStore((state) => state.accounts); const providerVendors = useProviderStore((state) => state.vendors); const refreshProviderSnapshot = useProviderStore((state) => state.refreshProviderSnapshot); + const pluginProjection = useCodingPluginsStore((state) => state.projection); + const pluginLoadState = useCodingPluginsStore((state) => state.loadState); const [draft, setDraft] = useState(null); const [drawerMode, setDrawerMode] = useState(null); const [activeSkillId, setActiveSkillId] = useState(null); @@ -185,17 +189,29 @@ export function ProjectConfiguration() { setActiveAgentId(requestedAgentId); setPartnerDialogOpen(true); }, [draft, location.search]); + useEffect(() => { + if (new URLSearchParams(location.search).get('resource') === 'plugins') setDrawerMode('plugins'); + }, [location.search]); useEffect(() => { loadSkills(); }, [loadSkills]); useEffect(() => { if (drawerMode === 'skills') loadSkills(); }, [drawerMode, loadSkills]); useEffect(() => { void refreshProviderSnapshot(); }, [refreshProviderSnapshot]); + useEffect(() => { + if (activeProject) void codingPluginsStore.getState().load(activeProject.id).catch(() => undefined); + }, [activeProject]); const modelOptions = useMemo( () => buildCodingModelOptions(providerAccounts, providerVendors), [providerAccounts, providerVendors], ); const activeAgent = draft?.agents.find((agent) => agent.id === activeAgentId) ?? null; const activeSkill = skills.find((skill) => skill.id === activeSkillId) ?? null; + const currentPluginProjection = pluginProjection?.project.localProjectId === activeProject?.id ? pluginProjection : null; + const availablePluginCount = currentPluginProjection?.items.length ?? 0; + const enabledPluginCount = currentPluginProjection?.items.filter(({ enabled }) => enabled).length ?? 0; + const pluginSummary = !currentPluginProjection + ? pluginLoadState === 'error' ? '项目插件暂不可用' : '正在读取项目插件' + : `${enabledPluginCount} 个已启用 · ${availablePluginCount} 个可用`; const playSaveConfirmation = () => undefined; const handleBack = () => navigate(-1); @@ -455,8 +471,8 @@ export function ProjectConfiguration() { )} -
} title="可用模型" subtitle={`${modelOptions.length} 个可用模型`} onClick={() => setDrawerMode('models')} />} title="可用技能" subtitle={`${skills.length} 项可用技能`} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} />} title="知识文件" subtitle={`${knowledge.length} 个知识文件`} onClick={() => setDrawerMode('knowledge')} />
-

项目智能体

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

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

已归档

{draft.agents.filter((agent) => agent.archivedAt).map((agent) => )}
: null}
+
} title="可用模型" subtitle={`${modelOptions.length} 个可用模型`} onClick={() => setDrawerMode('models')} />} title="可用技能" subtitle={`${skills.length} 项可用技能`} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} />} title="知识文件" subtitle={`${knowledge.length} 个知识文件`} onClick={() => setDrawerMode('knowledge')} />} title="项目插件" subtitle={pluginSummary} onClick={() => setDrawerMode('plugins')} />
+

项目智能体

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

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

已归档

{draft.agents.filter((agent) => agent.archivedAt).map((agent) => )}
: null}
@@ -486,11 +502,12 @@ export function ProjectConfiguration() { title={activeAgent ? `${activeAgent.name || '未命名智能体'} · 编辑智能体` : '创建项目智能体'} submitLabel={activeAgent ? '保存智能体' : '创建智能体'} /> - { if (!open) { setDrawerMode(null); setActiveSkillId(null); } }}> + { if (!open) { setDrawerMode(null); setActiveSkillId(null); } }}> {drawerMode === 'models' ? setDrawerMode(null)}> : null} {drawerMode === 'knowledge' ? setDrawerMode(null)}> { void handleKnowledge(event.target.files?.[0]); event.currentTarget.value = ''; }} />
{knowledge.length ? knowledge.map((file) =>
{file}
) :
当前还没有知识文件。
}
: null} {drawerMode === 'skills' && activeSkill ? setActiveSkillId(null)} closeLabel="返回技能列表" closeIcon="back"> : null} {drawerMode === 'skills' && !activeSkill ? setDrawerMode(null)}>
{skills.length > 0 ? skills.map((skill) => ) :
当前没有可查看的技能。
}
: null} + {drawerMode === 'plugins' ? setDrawerMode(null)}> setDrawerMode(null)} onManageAgents={() => setDrawerMode(null)} /> : null}
; onRemoveCollection(collection: string): void | Promise; onRemoveProject(): void | Promise; + onManageAgents?(): void; + onResolveIdentity?(): void; }; export function ProjectPluginsView(props: ViewProps) { @@ -69,16 +72,25 @@ export function ProjectPluginsView(props: ViewProps) { const pendingToggle = selected ? Boolean(props.pending[`enabled:${props.projection.project.localProjectId}:${selected.id}`]) : false; const libraryEntry = selected ? props.marketplaceLibrary?.items.find(({ pluginId }) => pluginId === selected.id) : null; const installation = selected ? props.installations?.[selected.id] : null; + const Root = props.embedded ? 'section' : 'main'; + const manageAgents = () => { + if (props.onManageAgents) props.onManageAgents(); + else navigate('/project-config'); + }; + const resolveIdentity = () => { + if (props.onResolveIdentity) props.onResolveIdentity(); + else navigate('/project-config'); + }; return ( -
-
-

项目插件

管理当前项目可用的首方插件。启用、后端配置和伙伴技能分配是三个独立步骤。

+ +
+ {!props.embedded ?

项目插件

管理当前项目可用的首方插件。启用、后端配置和伙伴技能分配是三个独立步骤。

: null}
{props.projection.policyStatus === 'stale' ?

正在显示上次可用的插件策略;服务恢复前不作为新的计费依据。

: null} {props.refreshError ?

刷新失败,正在保留上次项目插件页面。{props.refreshError}

: null} -
+
{props.projection.items.map((plugin) => (
- {selected.state === 'identity_required' ? : null} - {selected.state === 'authentication_required' ? : null} + {selected.state === 'identity_required' ? : null} + {selected.state === 'authentication_required' ? : null}

能力与权限

{selected.capabilities.map((capability) =>

{capability.id}

{capability.operations.map((operation) =>
{operation.id}{operation.tool ? <>{MUTATION_TEXT[operation.tool.mutation]}{operation.tool.name} : SDK}
{operation.tool ? <>

{operation.tool.description}

{operation.tool.permissions.map((permission) => PERMISSION_TEXT[permission] ?? permission).join('、')}

:

由生成的 Data Service SDK 使用,不作为 Pi 工具暴露。

}

{billingText(operation.billing)}

)}
)}
-

伙伴分配

+

伙伴分配

{selected.skills.flatMap(({ assignedAgentIds }) => assignedAgentIds).length ? selected.skills.flatMap(({ assignedAgentIds }) => assignedAgentIds).map((id) => {props.agentNames[id] ?? '未知伙伴'}) :

尚未分配给任何伙伴。

}

用量与计费

各操作的计费模式已在上方能力列表中逐项显示;平台计量操作的流水仍由权益所有者查看。

@@ -115,11 +127,18 @@ export function ProjectPluginsView(props: ViewProps) {
{props.projection.unknownPluginIds.length ?

配置保留、当前不可用

这些插件 ID 仍保存在项目配置中,但当前账号或设备没有可验证的插件定义。不会自动获取、下载、启用或分配。

    {props.projection.unknownPluginIds.map((pluginId) =>
  • {pluginId}
  • )}
: null} { if (!open) setDisableCandidate(null); }}>禁用{disableCandidate?.displayName}?禁用后,伙伴工具和预览访问会立即停止;云端数据会保留,不会被删除。 -
+ ); } -export function ProjectPlugins() { +export type ProjectPluginsProps = { + embedded?: boolean; + onManageAgents?(): void; + onResolveIdentity?(): void; + onChanged?(): void; +}; + +export function ProjectPlugins({ embedded = false, onManageAgents, onResolveIdentity, onChanged }: ProjectPluginsProps = {}) { const user = useAuthStore((state) => state.user); const accountKey = user?.userId ?? user?.username ?? null; const activeProject = useCodingWorkspaceStore((state) => state.activeProject); @@ -144,5 +163,13 @@ export function ProjectPlugins() { const safe = (promise: Promise) => promise.catch((reason) => { toast.error(reason instanceof Error ? reason.message : String(reason)); }); - return safe(codingPluginsStore.getState().load(activeProject.id))} onSetEnabled={(pluginId, enabled) => safe(codingPluginsStore.getState().setEnabled(activeProject.id, pluginId, enabled))} onConfigure={(collections) => safe(codingPluginsStore.getState().configure(collections))} onReset={() => safe(codingPluginsStore.getState().reset())} onRemoveCollection={(collection) => safe(codingPluginsStore.getState().removeCollection(collection))} onRemoveProject={() => safe(codingPluginsStore.getState().removeProject())} />; + const setEnabled = async (pluginId: string, enabled: boolean) => { + try { + await codingPluginsStore.getState().setEnabled(activeProject.id, pluginId, enabled); + onChanged?.(); + } catch (reason) { + toast.error(reason instanceof Error ? reason.message : String(reason)); + } + }; + return safe(codingPluginsStore.getState().load(activeProject.id))} onSetEnabled={setEnabled} onConfigure={(collections) => safe(codingPluginsStore.getState().configure(collections))} onReset={() => safe(codingPluginsStore.getState().reset())} onRemoveCollection={(collection) => safe(codingPluginsStore.getState().removeCollection(collection))} onRemoveProject={() => safe(codingPluginsStore.getState().removeProject())} onManageAgents={onManageAgents} onResolveIdentity={onResolveIdentity} />; } diff --git a/tests/e2e/plugin-marketplace.spec.ts b/tests/e2e/plugin-marketplace.spec.ts index d91fb14..b0c8d0c 100644 --- a/tests/e2e/plugin-marketplace.spec.ts +++ b/tests/e2e/plugin-marketplace.spec.ts @@ -36,6 +36,8 @@ test.describe('Plugin Marketplace', () => { }); await page.getByTestId('sidebar-nav-plugin-marketplace').click(); + await expect(page.getByTestId('sidebar-nav-my-plugins')).toHaveCount(0); + await expect(page.getByTestId('sidebar-nav-project-plugins')).toHaveCount(0); await expect(page.getByTestId('plugin-marketplace-page')).toBeVisible(); await expect(page.getByRole('heading', { name: '灵感笔记' })).toBeVisible(); await page.getByRole('searchbox', { name: '搜索插件' }).fill('笔记'); @@ -116,6 +118,10 @@ test.describe('Plugin Marketplace', () => { return response; } if (requestPath === '/api/auth/me') return result({ success: true, moduleAccess: { programming: true } }); + if (requestPath.startsWith('/api/coding/plugin-marketplace/catalog')) return result({ + items: [], nextCursor: null, total: 0, catalogGeneration: 1, + etag: '"plugins-1-tp-none"', pricingVersionId: null, stale: false, fetchedAt: 1, + }); if (requestPath === '/api/coding/plugin-marketplace/library') return result(projection()); if (requestPath === '/api/coding/plugin-marketplace/install/makelore.notes/beta' && method === 'POST') { installedChannel = 'beta'; @@ -149,7 +155,8 @@ test.describe('Plugin Marketplace', () => { await programmingOption.click(); await expect(page.getByTestId('main-layout')).toBeVisible(); await page.keyboard.press('Escape'); - await page.getByTestId('sidebar-nav-my-plugins').click(); + await page.getByTestId('sidebar-nav-plugin-marketplace').click(); + await page.getByRole('tab', { name: '我的插件' }).click(); await expect(page.getByTestId('my-plugins-page')).toBeVisible(); await expect.poll(async () => app.evaluate(() => ( ((globalThis as typeof globalThis & { __marketplaceE2E?: { requests: string[] } }).__marketplaceE2E?.requests ?? []) diff --git a/tests/e2e/project-plugins.spec.ts b/tests/e2e/project-plugins.spec.ts index 1f8d233..2a7e053 100644 --- a/tests/e2e/project-plugins.spec.ts +++ b/tests/e2e/project-plugins.spec.ts @@ -51,8 +51,12 @@ test.describe('Project Plugin Center', () => { }); }); - await page.getByTestId('sidebar-nav-project-plugins').click(); + await page.getByTestId('resource-card-plugins').click(); await expect(page.getByTestId('project-plugins-page')).toBeVisible(); + await page.getByTestId('project-plugins-page').getByRole('button', { name: '刷新' }).click(); + await expect.poll(async () => app.evaluate(() => ( + (globalThis as typeof globalThis & { __projectPluginE2E?: { localProjectId: string } }).__projectPluginE2E?.localProjectId + ))).toBeTruthy(); await expect(page.getByText('当前包含,不按单次调用扣点')).toBeVisible(); const localProjectId = await app.evaluate(() => ( (globalThis as typeof globalThis & { __projectPluginE2E?: { localProjectId: string } }).__projectPluginE2E?.localProjectId diff --git a/tests/unit/plugin-marketplace-pages.test.tsx b/tests/unit/plugin-marketplace-pages.test.tsx index 02313d3..5ec7a3a 100644 --- a/tests/unit/plugin-marketplace-pages.test.tsx +++ b/tests/unit/plugin-marketplace-pages.test.tsx @@ -92,7 +92,9 @@ describe('My Plugins', () => { expect(onRemove).toHaveBeenCalledWith('makelore.notes'); fireEvent.click(screen.getByRole('button', { name: '重新免费获取旧插件' })); expect(onReacquire).toHaveBeenCalledWith('makelore.removed'); - expect(screen.getAllByRole('link', { name: '启用到项目' }).length).toBeGreaterThan(0); + const projectLinks = screen.getAllByRole('link', { name: '启用到项目' }); + expect(projectLinks.length).toBeGreaterThan(0); + expect(projectLinks[0]).toHaveAttribute('href', '/project-config?resource=plugins'); expect(screen.getAllByRole('link', { name: '分配给伙伴' }).length).toBeGreaterThan(0); expect(screen.getByText('运行已暂停')).toBeVisible(); expect(screen.getByText('已退役,移除后不可重新获取')).toBeVisible(); @@ -129,6 +131,17 @@ describe('My Plugins', () => { expect(screen.getByRole('link', { name: '分配给伙伴' })).toBeVisible(); }); + it('renders as the account/device tab inside the unified plugin hub', () => { + render(); + + expect(screen.getByRole('heading', { name: '我的插件', level: 2 })).toBeVisible(); + expect(screen.getByTestId('my-plugins-page').tagName).toBe('SECTION'); + }); + it('updates an installed Beta package only through the explicit Beta action', () => { const onUpdate = vi.fn(); const onInstallBeta = vi.fn(); diff --git a/tests/unit/project-plugins-page.test.tsx b/tests/unit/project-plugins-page.test.tsx index d6ebeff..f520020 100644 --- a/tests/unit/project-plugins-page.test.tsx +++ b/tests/unit/project-plugins-page.test.tsx @@ -92,4 +92,14 @@ describe('Project Plugin Center', () => { fireEvent.click(screen.getByRole('button', { name: '从项目禁用保留插件 makelore.removed' })); expect(onSetEnabled).toHaveBeenCalledWith('makelore.removed', false); }); + + it('embeds project enablement in configuration and returns to Agent assignment explicitly', () => { + const onManageAgents = vi.fn(); + render(); + + expect(screen.getByTestId('project-plugins-page').tagName).toBe('SECTION'); + expect(screen.queryByRole('heading', { name: '项目插件' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '分配给伙伴' })); + expect(onManageAgents).toHaveBeenCalledTimes(1); + }); }); From e2379413991cf587f9fee52ff42b8f533679dc9a Mon Sep 17 00:00:00 2001 From: inman Date: Mon, 31 Aug 2026 16:57:26 +0800 Subject: [PATCH 04/30] feat: move plugin center into project configuration --- ...-consolidate-plugin-navigation-3d9a6c71.md | 53 +++++++------ README.md | 2 +- src/App.tsx | 14 +++- src/components/layout/Sidebar.tsx | 18 ----- src/components/plugins/PluginServices.tsx | 50 ++++++++++++ src/lib/plugin-services.ts | 6 ++ src/pages/MyPlugins/index.tsx | 4 +- src/pages/PluginMarketplace/index.tsx | 38 ++------- src/pages/ProjectConfiguration/index.tsx | 38 +++++++-- tests/e2e/plugin-marketplace.spec.ts | 79 ++++++++++++++++--- tests/e2e/project-plugins.spec.ts | 7 +- tests/unit/plugin-marketplace-pages.test.tsx | 7 +- tests/unit/plugin-services.test.tsx | 47 +++++++++++ 13 files changed, 260 insertions(+), 103 deletions(-) create mode 100644 src/components/plugins/PluginServices.tsx create mode 100644 src/lib/plugin-services.ts create mode 100644 tests/unit/plugin-services.test.tsx diff --git a/.project-docs/30-worklog/tasks/20260831-consolidate-plugin-navigation-3d9a6c71.md b/.project-docs/30-worklog/tasks/20260831-consolidate-plugin-navigation-3d9a6c71.md index b652731..8d6a955 100644 --- a/.project-docs/30-worklog/tasks/20260831-consolidate-plugin-navigation-3d9a6c71.md +++ b/.project-docs/30-worklog/tasks/20260831-consolidate-plugin-navigation-3d9a6c71.md @@ -12,51 +12,51 @@ ## Scope -- Replace the separate global “插件中心” and “我的插件” navigation entries with one plugin hub that exposes “发现插件” and “我的插件” as tabs. -- Remove the sidebar “项目插件” entry and embed project plugin enablement/configuration in the existing Project Configuration resource area beside models, skills, and knowledge. +- Remove all plugin entries from the programming sidebar and make Project Configuration’s “插件服务” resource the single user-facing plugin surface. +- Combine Marketplace discovery, account/device plugin management, and current-project enablement/configuration as URL-backed “发现插件”, “我的插件”, and “项目启用” tabs. - Preserve account acquisition, device installation, project enablement, Agent skill assignment, runtime authorization, and billing as independent lifecycle states. -- Keep `/my-plugins` and `/project-plugins` as compatibility redirects to the consolidated destinations. +- Keep `/plugin-marketplace`, `/my-plugins`, and `/project-plugins` as compatibility redirects into the appropriate Project Configuration tab. - Update focused unit tests, Electron E2E coverage, and README product-state documentation. ## Intent And Constraints - Reduce navigation duplication without changing Main-owned plugin contracts or `.makelore/project.json` authority. - Reuse the existing light visual system, Radix tabs/sheets, stores, and Host API clients; add no dependency or alternate backend path. -- Keep plugin discovery/account/device management global, while project enablement and Agent assignment remain project-scoped. +- Keep discovery/account/device data global even though its user-facing surface is nested under the active project’s configuration; project enablement and Agent assignment remain project-scoped. - Do not silently acquire, download, enable, assign, authorize, or bill as a side effect of another action. - Preserve unavailable/removed plugin IDs and explicit status messaging rather than inventing metadata or hiding retained configuration. ## Outcome -- Consolidated the two global plugin destinations into one `/plugin-marketplace` - hub. “发现插件” and “我的插件” are URL-backed tabs, and the sidebar now exposes - only the single “插件中心” entry. -- Removed the project plugin sidebar entry and added “项目插件” beside models, - Skills, and knowledge files in Project Configuration. The resource card reports - enabled/available counts and opens the existing project plugin projection in a - wider configuration sheet. +- Removed the remaining global Plugin Center from the programming sidebar. Project + Configuration now exposes one “插件服务” resource beside models, Skills, and + knowledge files. +- Added one wider Plugin Services sheet with URL-backed “发现插件”, “我的插件”, and + “项目启用” tabs. The resource card defaults to current-project enablement, while + compatibility and in-product links can deep-link directly to discovery or the + account/device library. - Reused the existing Marketplace, Library, device installation, project plugin, and Agent Skill stores. Enabling a project plugin refreshes the Skill projection, but does not acquire, download, configure a backend, or assign a Skill. -- Preserved compatibility for saved/deep links: `/my-plugins` redirects to the - “我的插件” tab and `/project-plugins` redirects to the Project Configuration - plugin sheet. My Plugins project actions now target the consolidated routes. -- Kept the standalone view variants available for focused rendering tests while - adding semantic embedded `section` variants for the tab and configuration sheet. +- Preserved compatibility for saved/deep links: `/plugin-marketplace`, + `/my-plugins`, and `/project-plugins` redirect to their corresponding Plugin + Services tab. My Plugins discovery and project actions now target those tabs. +- Reused semantic embedded `section` variants for all three tabs and removed the + routed standalone Plugin Center container. - Updated README product-state copy and focused unit/Electron E2E coverage. ## Verification - Exact `pnpm@10.33.4` was used for repository commands. - `pnpm run typecheck`: passed. -- Focused Vitest: 2 files / 17 tests passed. -- `pnpm test`: 212 files / 1,728 tests passed; 3 existing conditional tests skipped. +- Focused Vitest: 3 files / 19 tests passed. +- `pnpm test`: 213 test files / 1,730 tests passed; 3 existing conditional tests skipped. - `pnpm run lint:check`: passed with zero errors and five pre-existing warnings (`Home/index.tsx` hook dependency and four `Makelore/index.tsx` Fast Refresh warnings). - `pnpm run build:vite`: passed for Renderer, Electron Main, Preload, and utility worker; existing dynamic-import and large-chunk warnings remain non-fatal. -- Focused Electron E2E: 3/3 passed across unified plugin discovery/account-device - management and Project Configuration plugin enablement. +- Focused Electron E2E: 3/3 passed across Plugin Services discovery/account-device + management, sidebar removal, and current-project enablement. - `git diff --check`: passed. ## Follow-ups @@ -68,14 +68,15 @@ - Target canonical documents: `.project-docs/30-worklog/current-state.md` and `.project-docs/20-architecture/module-map.md`. -- Proposal: record that Renderer now exposes a single global Plugin Center with - discovery/account-device tabs, while project enablement/configuration is embedded - in Project Configuration beside models, Skills, and knowledge. -- Evidence: this task's source diff, 17 focused unit tests, the 1,728-test full unit +- Proposal: record that Renderer exposes no standalone plugin sidebar destination; + Project Configuration’s single Plugin Services sheet owns discovery, + account/device management, and current-project enablement tabs beside models, + Skills, and knowledge. +- Evidence: this task's source diff, 19 focused unit tests, the 1,730-test full unit suite, three focused Electron E2E flows, typecheck, lint, and production build. - Future impact: new navigation, documentation, and E2E work should target - `/plugin-marketplace` and `/project-config?resource=plugins`; the legacy routes are - compatibility redirects only. + `/project-config?resource=plugins&pluginTab=discover|mine|project`; all standalone + plugin routes are compatibility redirects only. - Semantic conflicts: none identified. The existing independent lifecycle and Main-owned trust boundaries remain unchanged. - Human confirmation: not required for the factual promotion; the user explicitly diff --git a/README.md b/README.md index db2c9b3..a8ec518 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人 - AI 绘画健壮性:Renderer 为每次命令生成稳定 operation id,网络结果未知时保留原命令供原样重试,不把未知写入当失败或创建第二次生成。Main 负责 Token 刷新、Agent Gateway REST 提交和有界 Run 查询,并将可恢复事件流投影为 Host API SSE;断线后按事件游标续接并重新读取权威 Workspace。Canvas 只使用 Works Square 云端 V2 契约,没有本地语义适配器或降级路径,上游不可用时明确报错。 - AI 绘画项目栏只展示 Workspace,不再在项目下创建独立设计会话。删除时必须完整输入项目名称;删除后项目、Living Form、任务、参考图和生成作品会从账户中隐藏且无法访问,不影响用户已另存到磁盘的副本。删除当前项目后自动打开最近更新的剩余项目,删除最后一个项目后进入空状态。 - AI 学习:主区展示服务端分页项目卡片,详情页用安全 Markdown 渲染 README;原始 HTML 被禁用,Markdown 图片节点直接加载服务端校验后的无凭据 HTTPS URL,包括 SVG 和 Electron 支持的其他图片格式,不经过服务端下载、识别、转码或镜像。下载按钮打开系统保存对话框,Main 不按 `Content-Length`、声明字节数或客户端上限阻断下载,流式校验 SHA-256 与 ZIP 签名后原子保存。客户端不提供课程生成、课程播放器、本地课程库、Agent、ASR 或课堂 runtime。运营管理与接口字段见 [`docs/learning-project-catalog-server-contract.md`](docs/learning-project-catalog-server-contract.md)。 -- 插件市场 Release A:侧栏只保留一个“插件中心”,其中“发现插件”与“我的插件”两个标签分别承载 Marketplace 浏览获取,以及账号 Library、本机安装/更新/删除设备包、移除后的 tombstone 和 bounded unavailable reason。项目插件入口位于项目配置,与模型、Skill 和知识文件并列;“免费获取”只写入账号 Library,“下载/更新”才写入本机 Package Store,“启用到项目”和“分配给伙伴”仍是彼此独立的动作,不会因获取或下载自动推进。 +- 插件市场 Release A:侧栏不再提供独立插件入口,项目配置中的“插件服务”统一承载“发现插件”“我的插件”“项目启用”三个标签。它们分别管理 Marketplace 浏览获取,账号 Library 与本机安装/更新/删除设备包,以及当前项目启用与配置;“免费获取”只写入账号 Library,“下载/更新”才写入本机 Package Store,“启用到项目”和“分配给伙伴”仍是彼此独立的动作,不会因获取或下载自动推进。旧插件路由仅保留兼容跳转。 - 插件运行架构:Renderer 只调用 Main-owned Marketplace facade;Main 负责账号、请求 deadline、签名/摘要校验、不可变 Release、current selection 与原子回滚。下一代 Pi worker 使用同一个 effective snapshot,将每个有效 Skill 与已验证 Package Store root 成对传给 resource loader、Extension Host 和 CLI;`skill_only` 不依赖运行时 Policy,也不执行分发包中的任意代码。正式激活仍等待官方 Ed25519 公钥(production key activation HOLD);生产私钥只能来自部署 secret,测试使用注入的临时密钥。 - 提示词博物馆:只陈列经过审核的作品预览、Prompt、分类以及作者/来源/许可证信息,支持搜索、使用场景/风格/主体筛选和详情抽屉;“使用此 Prompt”只把原文带回当前 Canvas 对话草稿,不自动发送、不构成社区。列表和详情数据由服务端提供,客户端不打包数据集;服务端字段契约见 [`docs/prompt-museum-server-contract.md`](docs/prompt-museum-server-contract.md)。 - 视觉系统:单一浅色主题,品牌蓝 `#3A5578`、星火橙 `#F26A3D`、白色画布与低饱和蓝灰层级。 diff --git a/src/App.tsx b/src/App.tsx index c3967a1..e3c3a9e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,7 +32,6 @@ const Chat = lazy(() => import('./pages/Chat').then(({ Chat: component }) => ({ const Home = lazy(() => import('./pages/Home').then(({ Home: component }) => ({ default: component }))); const PreviewScene = lazy(() => import('./pages/Makelore').then(({ PreviewScene: component }) => ({ default: component }))); const ProjectConfiguration = lazy(() => import('./pages/ProjectConfiguration').then(({ ProjectConfiguration: component }) => ({ default: component }))); -const PluginMarketplace = lazy(() => import('./pages/PluginMarketplace').then(({ PluginMarketplace: component }) => ({ default: component }))); const Workbench = lazy(() => import('./pages/Workbench').then(({ Workbench: component }) => ({ default: component }))); const ImageCanvas = lazy(() => import('./pages/ImageCanvas').then(({ ImageCanvas: component }) => ({ default: component }))); const ImagePromptMuseum = lazy(() => import('./pages/ImagePromptMuseum').then(({ ImagePromptMuseum: component }) => ({ default: component }))); @@ -201,6 +200,13 @@ function ModuleSelectionRoute({ return ; } +function LegacyPluginMarketplaceRedirect() { + const location = useLocation(); + const legacyTab = new URLSearchParams(location.search).get('tab'); + const pluginTab = legacyTab === 'mine' ? 'mine' : 'discover'; + return ; +} + function ProjectChatRoute() { const activeProject = useCodingWorkspaceStore((state) => state.activeProject); const loadProjectList = useCodingWorkspaceStore((state) => state.load); @@ -444,9 +450,9 @@ function App() { )} > } /> - } /> - } /> - } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index b123837..0f63801 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -12,7 +12,6 @@ import { FolderKanban, LogOut, Plus, - Store, Settings as SettingsIcon, UserCircle2, } from 'lucide-react'; @@ -586,23 +585,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi {!sidebarCollapsed && '新建项目'} -
- -
: null} + {props.library && props.library.items.length === 0 ?

账号插件库还是空的

前往“发现插件”免费获取需要的插件。

: null}
{props.library?.items.map((plugin) => { const installed = props.installations[plugin.pluginId]; @@ -89,7 +89,7 @@ export function MyPluginsView(props: MyPluginsViewProps) { {!removed && plugin.acquisition !== 'system_included' && plugin.betaVersion && installedChannel !== 'beta' ? : null} {!removed && plugin.acquisition !== 'system_included' ? : null} {hasDevicePackage ? : null} - +
; diff --git a/src/pages/PluginMarketplace/index.tsx b/src/pages/PluginMarketplace/index.tsx index 07c5be3..9d832a0 100644 --- a/src/pages/PluginMarketplace/index.tsx +++ b/src/pages/PluginMarketplace/index.tsx @@ -1,11 +1,8 @@ import { FormEvent, useEffect, useMemo, useState } from 'react'; import { CheckCircle2, Download, Puzzle, Search, ShieldCheck } from 'lucide-react'; -import { useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { MyPlugins } from '@/pages/MyPlugins'; import type { MarketplaceCatalogPage, MarketplaceCatalogQuery, @@ -54,7 +51,7 @@ export function PluginMarketplaceView(props: PluginMarketplaceViewProps) {
{props.embedded ?

发现插件

- :

插件中心

} + :

发现插件

}

浏览 MakeLore 运营发行的插件。免费获取只加入账号插件库,不会自动下载、启用项目或分配给伙伴。

{props.catalog ?

{props.catalog.total} 个插件

: null} @@ -114,9 +111,7 @@ export function PluginMarketplaceView(props: PluginMarketplaceViewProps) { ); } -export function PluginMarketplace() { - const [searchParams, setSearchParams] = useSearchParams(); - const activeTab = searchParams.get('tab') === 'mine' ? 'mine' : 'discover'; +export function PluginMarketplaceCatalog({ embedded = false }: { embedded?: boolean } = {}) { const user = useAuthStore((state) => state.user); const accountKey = user?.userId ?? user?.username ?? null; const catalog = usePluginMarketplaceStore((state) => state.catalog); @@ -128,34 +123,11 @@ export function PluginMarketplace() { const [selectedId, setSelectedId] = useState(null); useEffect(() => { pluginMarketplaceStore.getState().activateAccount(accountKey); - if (activeTab === 'discover') void pluginMarketplaceStore.getState().loadCatalog({ limit: 24 }).catch(() => undefined); + void pluginMarketplaceStore.getState().loadCatalog({ limit: 24 }).catch(() => undefined); if (accountKey) void pluginMarketplaceStore.getState().loadLibrary().catch(() => undefined); - }, [accountKey, activeTab]); + }, [accountKey]); const safe = (operation: Promise) => operation.catch((error) => { toast.error(error instanceof Error ? error.message : String(error)); }); - const handleTabChange = (value: string) => { - const next = new URLSearchParams(searchParams); - if (value === 'mine') next.set('tab', 'mine'); - else next.delete('tab'); - setSearchParams(next, { replace: true }); - }; - return
-
-

插件中心

-

在同一处发现插件,并管理账号插件库与本机下载。获取、下载、项目启用和伙伴分配始终是彼此独立的步骤。

-
- - - 发现插件 - 我的插件 - - - safe(pluginMarketplaceStore.getState().loadCatalog(input))} onSelect={(pluginId) => { setSelectedId(pluginId); return safe(pluginMarketplaceStore.getState().loadDetail(pluginId)); }} onAcquire={(pluginId) => safe(pluginMarketplaceStore.getState().acquire(pluginId))} /> - - - - - -
; + return safe(pluginMarketplaceStore.getState().loadCatalog(input))} onSelect={(pluginId) => { setSelectedId(pluginId); return safe(pluginMarketplaceStore.getState().loadDetail(pluginId)); }} onAcquire={(pluginId) => safe(pluginMarketplaceStore.getState().acquire(pluginId))} />; } diff --git a/src/pages/ProjectConfiguration/index.tsx b/src/pages/ProjectConfiguration/index.tsx index 4dbd8ad..739e861 100644 --- a/src/pages/ProjectConfiguration/index.tsx +++ b/src/pages/ProjectConfiguration/index.tsx @@ -7,13 +7,14 @@ import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'; import { AgentCreationDialog, type AgentCreationInput } from '@/components/coding/AgentCreationDialog'; +import { PluginServices } from '@/components/plugins/PluginServices'; import { ProjectPublishAction } from '@/components/works/ProjectPublishAction'; -import { ProjectPlugins } from '@/pages/ProjectPlugins'; import { buildCodingModelOptions, parseCodingModelKey, type CodingModelOption } from '@/lib/coding-model-options'; import { getCodingSkills } from '@/lib/coding-product-tools'; import { abortCodingConversation } from '@/lib/coding-conversations'; import { isCanonicalCodingProjectId } from '@/lib/coding-projects'; import { getSkillDisplayInfo } from '@/lib/skill-display'; +import { getPluginServiceTab, type PluginServiceTab } from '@/lib/plugin-services'; import { getAgentAvatarSrc } from '@/lib/agent-avatars'; import { cn } from '@/lib/utils'; import { codingConversationStore } from '@/stores/coding-conversations'; @@ -147,6 +148,7 @@ export function ProjectConfiguration() { const pluginLoadState = useCodingPluginsStore((state) => state.loadState); const [draft, setDraft] = useState(null); const [drawerMode, setDrawerMode] = useState(null); + const [pluginServiceTab, setPluginServiceTab] = useState('project'); const [activeSkillId, setActiveSkillId] = useState(null); const [activeAgentId, setActiveAgentId] = useState(null); const [skills, setSkills] = useState([]); @@ -190,7 +192,10 @@ export function ProjectConfiguration() { setPartnerDialogOpen(true); }, [draft, location.search]); useEffect(() => { - if (new URLSearchParams(location.search).get('resource') === 'plugins') setDrawerMode('plugins'); + const params = new URLSearchParams(location.search); + if (params.get('resource') !== 'plugins') return; + setPluginServiceTab(getPluginServiceTab(params.get('pluginTab'))); + setDrawerMode('plugins'); }, [location.search]); useEffect(() => { loadSkills(); }, [loadSkills]); useEffect(() => { @@ -214,6 +219,29 @@ export function ProjectConfiguration() { : `${enabledPluginCount} 个已启用 · ${availablePluginCount} 个可用`; const playSaveConfirmation = () => undefined; const handleBack = () => navigate(-1); + const handlePluginServiceTabChange = (tab: PluginServiceTab) => { + setPluginServiceTab(tab); + const params = new URLSearchParams(location.search); + params.set('resource', 'plugins'); + params.set('pluginTab', tab); + navigate({ pathname: '/project-config', search: `?${params.toString()}` }, { replace: true }); + }; + const openPluginServices = () => { + setPluginServiceTab('project'); + setDrawerMode('plugins'); + const params = new URLSearchParams(location.search); + params.set('resource', 'plugins'); + params.set('pluginTab', 'project'); + navigate({ pathname: '/project-config', search: `?${params.toString()}` }, { replace: true }); + }; + const closePluginServices = () => { + setDrawerMode(null); + const params = new URLSearchParams(location.search); + params.delete('resource'); + params.delete('pluginTab'); + const search = params.toString(); + navigate({ pathname: '/project-config', search: search ? `?${search}` : '' }, { replace: true }); + }; if (!activeProject) return

一念成光,万物可创。

; if (!draft) return
正在读取项目配置…
; @@ -471,7 +499,7 @@ export function ProjectConfiguration() { )} -
} title="可用模型" subtitle={`${modelOptions.length} 个可用模型`} onClick={() => setDrawerMode('models')} />} title="可用技能" subtitle={`${skills.length} 项可用技能`} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} />} title="知识文件" subtitle={`${knowledge.length} 个知识文件`} onClick={() => setDrawerMode('knowledge')} />} title="项目插件" subtitle={pluginSummary} onClick={() => setDrawerMode('plugins')} />
+
} title="可用模型" subtitle={`${modelOptions.length} 个可用模型`} onClick={() => setDrawerMode('models')} />} title="可用技能" subtitle={`${skills.length} 项可用技能`} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} />} title="知识文件" subtitle={`${knowledge.length} 个知识文件`} onClick={() => setDrawerMode('knowledge')} />} title="插件服务" subtitle={pluginSummary} onClick={openPluginServices} />

项目智能体

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

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

已归档

{draft.agents.filter((agent) => agent.archivedAt).map((agent) => )}
: null}
@@ -502,12 +530,12 @@ export function ProjectConfiguration() { title={activeAgent ? `${activeAgent.name || '未命名智能体'} · 编辑智能体` : '创建项目智能体'} submitLabel={activeAgent ? '保存智能体' : '创建智能体'} /> - { if (!open) { setDrawerMode(null); setActiveSkillId(null); } }}> + { if (!open) { if (drawerMode === 'plugins') closePluginServices(); else setDrawerMode(null); setActiveSkillId(null); } }}> {drawerMode === 'models' ? setDrawerMode(null)}> : null} {drawerMode === 'knowledge' ? setDrawerMode(null)}> { void handleKnowledge(event.target.files?.[0]); event.currentTarget.value = ''; }} />
{knowledge.length ? knowledge.map((file) =>
{file}
) :
当前还没有知识文件。
}
: null} {drawerMode === 'skills' && activeSkill ? setActiveSkillId(null)} closeLabel="返回技能列表" closeIcon="back"> : null} {drawerMode === 'skills' && !activeSkill ? setDrawerMode(null)}>
{skills.length > 0 ? skills.map((skill) => ) :
当前没有可查看的技能。
}
: null} - {drawerMode === 'plugins' ? setDrawerMode(null)}> setDrawerMode(null)} onManageAgents={() => setDrawerMode(null)} /> : null} + {drawerMode === 'plugins' ? : null}
{ - test('opens the global catalog, searches, and reads detail through fixed Main routes', async ({ launchElectronApp }) => { +test.describe('Project Plugin Services marketplace tabs', () => { + test('opens discovery inside project configuration, searches, and reads detail through fixed Main routes', async ({ launchElectronApp }) => { + const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-marketplace-e2e-')); const app = await launchElectronApp({ skipSetup: true }); try { + await app.evaluate(({ ipcMain }, selectedPath) => { + ipcMain.removeHandler('dialog:open'); + ipcMain.handle('dialog:open', async () => ({ canceled: false, filePaths: [selectedPath] })); + }, projectPath); const page = await getStableWindow(app); await page.getByTestId('ai-module-option-programming').click(); + await page.getByTestId('sidebar-create-project').click(); + await page.getByRole('button', { name: '选择路径' }).click(); + await page.getByRole('button', { name: '确认创建' }).click(); + await expect(page.getByTestId('project-configuration-page')).toBeVisible(); await app.evaluate(({ ipcMain }) => { const requests: string[] = []; (globalThis as typeof globalThis & { __marketplaceE2E?: { requests: string[] } }).__marketplaceE2E = { requests }; @@ -31,14 +43,25 @@ test.describe('Plugin Marketplace', () => { etag: '"plugins-1-tp-none"', pricingVersionId: null, stale: false, fetchedAt: 1, } } }; } + if (requestPath.startsWith('/api/coding/plugins')) { + const localProjectId = new URL(requestPath, 'https://makelore.local').searchParams.get('projectId') ?? ''; + return { ok: true, data: { status: 200, ok: true, json: { + schemaVersion: 1, + project: { localProjectId, durableProjectId: '11111111-1111-4111-8111-111111111111' }, + policyStatus: 'current', unknownPluginIds: [], items: [], + } } }; + } return { ok: false, error: { message: `Unexpected Host API request: ${requestPath}` } }; }); }); - await page.getByTestId('sidebar-nav-plugin-marketplace').click(); + await page.getByTestId('resource-card-plugins').click(); + await page.getByRole('tab', { name: '发现插件' }).click(); + await expect(page.getByRole('dialog', { name: '插件服务' })).toBeVisible(); + await expect(page.getByTestId('sidebar-nav-plugin-marketplace')).toHaveCount(0); await expect(page.getByTestId('sidebar-nav-my-plugins')).toHaveCount(0); await expect(page.getByTestId('sidebar-nav-project-plugins')).toHaveCount(0); - await expect(page.getByTestId('plugin-marketplace-page')).toBeVisible(); + await expect(page.getByTestId('plugin-marketplace-discover')).toBeVisible(); await expect(page.getByRole('heading', { name: '灵感笔记' })).toBeVisible(); await page.getByRole('searchbox', { name: '搜索插件' }).fill('笔记'); await page.getByRole('button', { name: '搜索', exact: true }).click(); @@ -56,15 +79,39 @@ test.describe('Plugin Marketplace', () => { expect(JSON.stringify(marketplaceRequests)).not.toMatch(/account|admission|release_id|path=/i); } finally { await closeElectronApp(app); + await rm(projectPath, { recursive: true, force: true }); } }); test('keeps Beta explicit and exposes bounded unavailable, disabled, and device-delete states', async ({ launchElectronApp }) => { + const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-my-plugins-e2e-')); const app = await launchElectronApp({ skipSetup: true }); try { + await app.evaluate(({ ipcMain }, selectedPath) => { + ipcMain.removeHandler('dialog:open'); + ipcMain.handle('dialog:open', async () => ({ canceled: false, filePaths: [selectedPath] })); + }, projectPath); const page = await getStableWindow(app); await expect(page.getByTestId('ai-module-option-programming')).toBeVisible(); - await app.evaluate(({ ipcMain }) => { + await page.getByTestId('ai-module-option-programming').click(); + await page.getByTestId('sidebar-create-project').click(); + await page.getByRole('button', { name: '选择路径' }).click(); + await page.getByRole('button', { name: '确认创建' }).click(); + await expect(page.getByTestId('project-configuration-page')).toBeVisible(); + const cachedProjectResponses = await page.evaluate(async () => { + const list = await window.electron.ipcRenderer.invoke('hostapi:fetch', { + path: '/api/coding/projects', method: 'GET', + }); + const activeProjectId = (list as { data?: { json?: { activeProjectId?: string } } }).data?.json?.activeProjectId ?? ''; + const config = await window.electron.ipcRenderer.invoke('hostapi:fetch', { + path: `/api/coding/projects/config?projectId=${encodeURIComponent(activeProjectId)}`, method: 'GET', + }); + const conversations = await window.electron.ipcRenderer.invoke('hostapi:fetch', { + path: `/api/coding/projects/conversations?projectId=${encodeURIComponent(activeProjectId)}`, method: 'GET', + }); + return { list, config, conversations }; + }); + await app.evaluate(({ ipcMain }, projectResponses: { list: unknown; config: unknown; conversations: unknown }) => { let installedChannel: 'stable' | 'beta' | null = 'stable'; const installedVersion = '1.0.0'; const requests: string[] = []; @@ -118,10 +165,21 @@ test.describe('Plugin Marketplace', () => { return response; } if (requestPath === '/api/auth/me') return result({ success: true, moduleAccess: { programming: true } }); + if (requestPath === '/api/coding/projects') return projectResponses.list; + if (requestPath.startsWith('/api/coding/projects/config')) return projectResponses.config; + if (requestPath.startsWith('/api/coding/projects/conversations')) return projectResponses.conversations; if (requestPath.startsWith('/api/coding/plugin-marketplace/catalog')) return result({ items: [], nextCursor: null, total: 0, catalogGeneration: 1, etag: '"plugins-1-tp-none"', pricingVersionId: null, stale: false, fetchedAt: 1, }); + if (requestPath.startsWith('/api/coding/plugins')) { + const localProjectId = new URL(requestPath, 'https://makelore.local').searchParams.get('projectId') ?? ''; + return result({ + schemaVersion: 1, + project: { localProjectId, durableProjectId: '11111111-1111-4111-8111-111111111111' }, + policyStatus: 'current', unknownPluginIds: [], items: [], + }); + } if (requestPath === '/api/coding/plugin-marketplace/library') return result(projection()); if (requestPath === '/api/coding/plugin-marketplace/install/makelore.notes/beta' && method === 'POST') { installedChannel = 'beta'; @@ -133,7 +191,7 @@ test.describe('Plugin Marketplace', () => { } return { ok: false, error: { message: `Unexpected Host API request: ${method} ${requestPath}` } }; }); - }); + }, cachedProjectResponses); await page.addInitScript(({ key, value }) => { localStorage.setItem(key, value); }, { @@ -150,13 +208,13 @@ test.describe('Plugin Marketplace', () => { }), }); await page.reload({ waitUntil: 'domcontentloaded' }); - await expect(page.getByTestId('ai-module-option-programming')).toBeVisible(); - const programmingOption = page.getByTestId('ai-module-option-programming'); - await programmingOption.click(); await expect(page.getByTestId('main-layout')).toBeVisible(); await page.keyboard.press('Escape'); - await page.getByTestId('sidebar-nav-plugin-marketplace').click(); + await expect(page.getByTestId('project-configuration-page')).toBeVisible(); + await page.getByTestId('resource-card-plugins').click(); await page.getByRole('tab', { name: '我的插件' }).click(); + await expect(page.getByRole('dialog', { name: '插件服务' })).toBeVisible(); + await expect(page.getByTestId('sidebar-nav-plugin-marketplace')).toHaveCount(0); await expect(page.getByTestId('my-plugins-page')).toBeVisible(); await expect.poll(async () => app.evaluate(() => ( ((globalThis as typeof globalThis & { __marketplaceE2E?: { requests: string[] } }).__marketplaceE2E?.requests ?? []) @@ -184,6 +242,7 @@ test.describe('Plugin Marketplace', () => { expect(requests).toContain('DELETE /api/coding/plugin-marketplace/install/makelore.notes'); } finally { await closeElectronApp(app); + await rm(projectPath, { recursive: true, force: true }); } }); }); diff --git a/tests/e2e/project-plugins.spec.ts b/tests/e2e/project-plugins.spec.ts index 2a7e053..800e4dd 100644 --- a/tests/e2e/project-plugins.spec.ts +++ b/tests/e2e/project-plugins.spec.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; -test.describe('Project Plugin Center', () => { +test.describe('Project Plugin Services', () => { test('enables selection without configuring Data Service or duplicating project IDs', async ({ launchElectronApp }) => { const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-project-plugins-e2e-')); const app = await launchElectronApp({ skipSetup: true }); @@ -52,6 +52,11 @@ test.describe('Project Plugin Center', () => { }); await page.getByTestId('resource-card-plugins').click(); + await expect(page.getByRole('dialog', { name: '插件服务' })).toBeVisible(); + await expect(page.getByRole('tab', { name: '发现插件' })).toBeVisible(); + await expect(page.getByRole('tab', { name: '我的插件' })).toBeVisible(); + await expect(page.getByRole('tab', { name: '项目启用' })).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByTestId('sidebar-nav-plugin-marketplace')).toHaveCount(0); await expect(page.getByTestId('project-plugins-page')).toBeVisible(); await page.getByTestId('project-plugins-page').getByRole('button', { name: '刷新' }).click(); await expect.poll(async () => app.evaluate(() => ( diff --git a/tests/unit/plugin-marketplace-pages.test.tsx b/tests/unit/plugin-marketplace-pages.test.tsx index 5ec7a3a..f03d4b5 100644 --- a/tests/unit/plugin-marketplace-pages.test.tsx +++ b/tests/unit/plugin-marketplace-pages.test.tsx @@ -36,7 +36,7 @@ describe('Plugin Marketplace pages', () => { onSearch={onSearch} onSelect={vi.fn()} onAcquire={onAcquire} />); - expect(screen.getByRole('heading', { name: '插件中心' })).toHaveClass('text-balance'); + expect(screen.getByRole('heading', { name: '发现插件' })).toHaveClass('text-balance'); expect(screen.getByText('部分能力包含,部分按 Token Point 用量计费')).toBeVisible(); expect(screen.getByText('1')).toHaveClass('tabular-nums'); fireEvent.change(screen.getByRole('searchbox', { name: '搜索插件' }), { target: { value: '笔记' } }); @@ -94,7 +94,7 @@ describe('My Plugins', () => { expect(onReacquire).toHaveBeenCalledWith('makelore.removed'); const projectLinks = screen.getAllByRole('link', { name: '启用到项目' }); expect(projectLinks.length).toBeGreaterThan(0); - expect(projectLinks[0]).toHaveAttribute('href', '/project-config?resource=plugins'); + expect(projectLinks[0]).toHaveAttribute('href', '/project-config?resource=plugins&pluginTab=project'); expect(screen.getAllByRole('link', { name: '分配给伙伴' }).length).toBeGreaterThan(0); expect(screen.getByText('运行已暂停')).toBeVisible(); expect(screen.getByText('已退役,移除后不可重新获取')).toBeVisible(); @@ -131,7 +131,7 @@ describe('My Plugins', () => { expect(screen.getByRole('link', { name: '分配给伙伴' })).toBeVisible(); }); - it('renders as the account/device tab inside the unified plugin hub', () => { + it('renders as the account/device tab inside project plugin services', () => { render( { expect(screen.getByRole('heading', { name: '我的插件', level: 2 })).toBeVisible(); expect(screen.getByTestId('my-plugins-page').tagName).toBe('SECTION'); + expect(screen.getByRole('link', { name: '浏览插件' })).toHaveAttribute('href', '/project-config?resource=plugins&pluginTab=discover'); }); it('updates an installed Beta package only through the explicit Beta action', () => { diff --git a/tests/unit/plugin-services.test.tsx b/tests/unit/plugin-services.test.tsx new file mode 100644 index 0000000..76e0f62 --- /dev/null +++ b/tests/unit/plugin-services.test.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; +import { PluginServices } from '@/components/plugins/PluginServices'; +import { getPluginServiceTab, type PluginServiceTab } from '@/lib/plugin-services'; + +vi.mock('@/pages/PluginMarketplace', () => ({ + PluginMarketplaceCatalog: () =>
发现插件内容
, +})); +vi.mock('@/pages/MyPlugins', () => ({ + MyPlugins: () =>
我的插件内容
, +})); +vi.mock('@/pages/ProjectPlugins', () => ({ + ProjectPlugins: () =>
项目启用内容
, +})); + +function Harness() { + const [value, setValue] = useState('project'); + return ; +} + +describe('PluginServices', () => { + it('keeps discovery, account/device management, and project enablement in one surface', () => { + render(); + + expect(screen.getByRole('tab', { name: '项目启用' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('项目启用内容')).toBeVisible(); + fireEvent.mouseDown(screen.getByRole('tab', { name: '发现插件' }), { button: 0, ctrlKey: false }); + expect(screen.getByText('发现插件内容')).toBeVisible(); + fireEvent.mouseDown(screen.getByRole('tab', { name: '我的插件' }), { button: 0, ctrlKey: false }); + expect(screen.getByText('我的插件内容')).toBeVisible(); + }); + + it('defaults invalid and missing deep-link values to project enablement', () => { + expect(getPluginServiceTab(null)).toBe('project'); + expect(getPluginServiceTab('unknown')).toBe('project'); + expect(getPluginServiceTab('discover')).toBe('discover'); + expect(getPluginServiceTab('mine')).toBe('mine'); + }); +}); From e92b1d2f33094a8687966d76aef535fecd5e2632 Mon Sep 17 00:00:00 2001 From: inman Date: Tue, 1 Sep 2026 10:29:27 +0800 Subject: [PATCH 05/30] docs: record local main fast-forward --- .../tasks/20260901-pull-main-6f2a9c1d.md | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .project-docs/30-worklog/tasks/20260901-pull-main-6f2a9c1d.md diff --git a/.project-docs/30-worklog/tasks/20260901-pull-main-6f2a9c1d.md b/.project-docs/30-worklog/tasks/20260901-pull-main-6f2a9c1d.md new file mode 100644 index 0000000..087c039 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-pull-main-6f2a9c1d.md @@ -0,0 +1,62 @@ +# Task: Pull remote and fast-forward main + +## Identity + +- Task ID: 20260901-pull-main-6f2a9c1d +- Mode: Integration +- Branch: main +- Worktree: /Users/inmanx/Documents/makelore +- Base commit: 38f85f6b5e4dc4e2c5e5b9f8f4506554cfd578f5 +- Owner: codex +- Status: Transferred to Final Integration Gate + +## Scope + +- Fetch `origin` and fast-forward the local `main` branch from `38f85f6` to + the verified remote `origin/main` tip. +- Preserve the adopted untracked review task record and make no independent + product-source changes. + +## Intent And Constraints + +- Use a fast-forward-only merge so this task cannot synthesize an unreviewed + merge commit or resolve content conflicts implicitly. +- Keep all existing local task records intact and verify branch ancestry, + remote parity, and worktree state after the update. + +## Outcome + +- Fetched `origin` and fast-forwarded local `main` by nine commits, from + `38f85f6b5e4dc4e2c5e5b9f8f4506554cfd578f5` to + `bf32fecd6013101485434b1977a73beb8b6a1fe3`. +- The update used `git pull --ff-only origin main`; no conflict resolution or + additional merge commit was created. +- Preserved the adopted untracked review record + `20260831-review-local-update-5b8c21.md` unchanged. +- The task-aware drift check correctly identified the five immutable source + task records and product files introduced by the remote fast-forward as + changes after this task's recorded base. Those files were not edited or + re-authored by this task; final ownership transfers to a fresh Integration + task whose base already contains the remote history. + +## Verification + +- `git rev-parse HEAD` and `git rev-parse origin/main` both returned + `bf32fecd6013101485434b1977a73beb8b6a1fe3`. +- `git rev-list --left-right --count main...origin/main` returned `0 0`. +- `git merge-base --is-ancestor 38f85f6 HEAD` passed. +- `git diff --check 38f85f6..HEAD` passed. +- No code tests were rerun because this task performed an exact fast-forward of + the already-committed remote history and introduced no independent source + edits. +- The first task-aware documentation drift check was blocked by the imported + remote source records, as required by the ownership boundary. + +## Follow-ups + +- Close the documentation gate from a fresh Integration task based on the + synchronized `main` tip; do not push remote history unless explicitly asked. + +## Promotion Candidates + +- None recorded. From c515e3bc75de845089ba182ae66917c84d2cfbca Mon Sep 17 00:00:00 2001 From: inman Date: Tue, 1 Sep 2026 10:30:26 +0800 Subject: [PATCH 06/30] docs: close local main sync gate --- .../20260901-close-main-sync-91d4e7c2.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .project-docs/30-worklog/tasks/20260901-close-main-sync-91d4e7c2.md diff --git a/.project-docs/30-worklog/tasks/20260901-close-main-sync-91d4e7c2.md b/.project-docs/30-worklog/tasks/20260901-close-main-sync-91d4e7c2.md new file mode 100644 index 0000000..76e2c39 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-close-main-sync-91d4e7c2.md @@ -0,0 +1,52 @@ +# Task: Close local main sync documentation gate + +## Identity + +- Task ID: 20260901-close-main-sync-91d4e7c2 +- Mode: Integration +- Branch: main +- Worktree: /Users/inmanx/Documents/makelore +- Base commit: e92b1d2f33094a8687966d76aef535fecd5e2632 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Close the Task Documentation Gate after the remote fast-forward and its + source task records are already present in the recorded base. +- Verify the final local `main` topology, task ownership boundaries, and the + absence of product changes introduced by the documentation closure. + +## Intent And Constraints + +- Own `main` and the repository Integration lock at base `e92b1d2`. +- Do not change product code, shared canonical memory, source task records, + remote refs, deployment state, or production activation gates. +- Keep the adopted review record byte-preserved and locally excluded from Git + tracking; do not delete, move, or commit it. + +## Outcome + +- Confirmed local `main` contains remote tip + `bf32fecd6013101485434b1977a73beb8b6a1fe3` and the nine fetched commits. +- Confirmed the only local commits after `origin/main` are task-documentation + closure records; no product file was changed locally after the fast-forward. +- No push, deployment, publication, or production operation was performed. + +## Verification + +- `origin/main` is an ancestor of local `main`. +- `git diff --check 38f85f6..bf32fec` passed for the fetched history. +- Before documentation closure, `HEAD` and `origin/main` both resolved to + `bf32fecd6013101485434b1977a73beb8b6a1fe3`, and the ahead/behind count was + `0 0`. +- No code tests were rerun because this closure adds only its own task record; + the fetched product history remains byte-identical to `origin/main`. + +## Follow-ups + +- Push the local documentation commits only if explicitly requested. + +## Promotion Candidates + +- None recorded. From 9ed70ba925a7e544f17bf5b418846df9d4145a2a Mon Sep 17 00:00:00 2001 From: inman Date: Tue, 1 Sep 2026 11:39:17 +0800 Subject: [PATCH 07/30] docs: record current development startup --- .../tasks/20260901-start-dev-3a8f2c91.md | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .project-docs/30-worklog/tasks/20260901-start-dev-3a8f2c91.md diff --git a/.project-docs/30-worklog/tasks/20260901-start-dev-3a8f2c91.md b/.project-docs/30-worklog/tasks/20260901-start-dev-3a8f2c91.md new file mode 100644 index 0000000..0129558 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-start-dev-3a8f2c91.md @@ -0,0 +1,78 @@ +# Task: Start current Makelore development environment + +## Identity + +- Task ID: 20260901-start-dev-3a8f2c91 +- Mode: Feature +- Branch: main +- Worktree: /Users/inmanx/Documents/makelore +- Base commit: c515e3bc75de845089ba182ae66917c84d2cfbca +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Prepare dependencies for the current local `main` checkout and start the + Makelore Electron development environment. +- Verify the Renderer, Electron Main Host API, and supporting local runtime are + alive, then keep the development process running for the user. + +## Intent And Constraints + +- Use the repository-pinned `pnpm@10.33.4` and `--frozen-lockfile`. +- Make no product-source, manifest, lockfile, dependency-version, account, or + external-service changes. +- Start from `/Users/inmanx/Documents/makelore`; do not reuse the older + development worktree or its stale task context. + +## Outcome + +- Installed the existing lockfile dependency graph with repository-pinned + `pnpm@10.33.4`; the lockfile was already current and no dependency version + changed. +- Started `pnpm run dev` from the current `main` checkout and retained the live + terminal session `4255`. +- Vite serves the Renderer on `http://localhost:5173/`, and Electron Main owns + the Host API listener on `127.0.0.1:13210`. +- Electron application PID `30847` and its helper processes remained alive + throughout the requested development session. +- Follow-up UI diagnosis confirmed that current `main` does not contain the + completed local Plugin navigation consolidation. Commits `b3f4166` and + `e237941` remain only on + `codex/20260831-consolidate-plugin-navigation-3d9a6c71-consolidate-plugin-navigation`. +- Stopped terminal session `4255`, Vite, Electron, and the Pi Agent Server at + the start of the user-requested all-local-changes integration so files can be + merged and verified from a quiescent worktree. + +## Verification + +- `pnpm install --frozen-lockfile`: passed with pnpm `10.33.4`. +- Vite reported ready and completed the Electron Main, Preload, and release + utility worker development builds. +- `curl http://localhost:5173/`: HTTP 200; Vite listens on IPv6 loopback + `[::1]:5173`. +- `lsof` confirmed Electron PID `30847` listening on `127.0.0.1:13210`. +- `package.json` and `pnpm-lock.yaml` remain unchanged. +- Non-blocking warnings: macOS denied changing the login item, and the local + Browserslist database reports that it is six months old. +- `git cherry main ` marked both Plugin navigation + commits with `+`; neither patch is present on `main`. +- The refreshed remote advertises only `main` at `bf32fec` and + `codex/performance-optimization`; no remote ref contains either Plugin + navigation commit. +- Current `main` still declares standalone `/plugin-marketplace`, + `/my-plugins`, and `/project-plugins` routes and the two Plugin sidebar + buttons. The unmerged branch removes those buttons and redirects the routes + into Project Configuration's Plugin Services tabs. + +## Follow-ups + +- Restart the development environment after the requested local integration is + complete and verified. +- If requested, integrate the two Plugin navigation commits onto current + `main`, reconcile them with the newer hosted-Plugin history, rerun focused + UI/E2E verification, and restart the development environment. + +## Promotion Candidates + +- None recorded. From d523b72cf86a54c596d60411a59e491142f8d624 Mon Sep 17 00:00:00 2001 From: inman Date: Tue, 1 Sep 2026 11:46:04 +0800 Subject: [PATCH 08/30] fix: restore coding history and quota feedback --- ...60831-fix-history-quota-errors-6c2a91e4.md | 104 ++++++++++++++++++ electron/api/routes/ai-proxy.ts | 3 +- electron/coding-runtime/pi/event-projector.ts | 17 +-- .../coding-runtime/pi/provider-failure.ts | 25 +++++ .../coding-runtime/pi/session-projector.ts | 49 +++++---- shared/ai-gateway-error-kind.ts | 4 +- shared/coding-conversation-contracts.ts | 1 + shared/coding-conversation-reducer.ts | 1 + src/pages/Chat/CodingConversationTimeline.tsx | 47 +++++++- tests/e2e/pi-coding-first-chat.spec.ts | 67 ++++++++++- tests/unit/ai-proxy-routes.test.ts | 34 ++++++ .../coding-conversation-timeline.test.tsx | 91 ++++++++++++++- tests/unit/pi-event-projector.test.ts | 34 ++++++ tests/unit/pi-session-projector.test.ts | 52 ++++++++- 14 files changed, 480 insertions(+), 49 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260831-fix-history-quota-errors-6c2a91e4.md create mode 100644 electron/coding-runtime/pi/provider-failure.ts diff --git a/.project-docs/30-worklog/tasks/20260831-fix-history-quota-errors-6c2a91e4.md b/.project-docs/30-worklog/tasks/20260831-fix-history-quota-errors-6c2a91e4.md new file mode 100644 index 0000000..745a9c0 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260831-fix-history-quota-errors-6c2a91e4.md @@ -0,0 +1,104 @@ +# Task: Fix Coding history loading and quota errors + +## Identity + +- Task ID: 20260831-fix-history-quota-errors-6c2a91e4 +- Mode: Feature +- Branch: codex/20260831-fix-history-quota-errors-6c2a91e4-fix-history-quota-errors-6c2a91e4 +- Worktree: /Users/inmanx/Documents/makelore-fix-history-quota-errors-6c2a91e4 +- Base commit: 38f85f6b5e4dc4e2c5e5b9f8f4506554cfd578f5 +- Owner: codex +- Status: Ready for integration + +## Scope + +- Restore access to the full persisted active-branch history of Makelore Code + conversations after Pi compaction, while keeping history display bounded in + the Renderer and preserving the user's scroll anchor when older content is + prepended. +- Recognize the observed upstream `403 token_point_balance_exhausted` contract + as a non-retryable Token Point quota failure and show actionable, safe + Chinese feedback instead of the generic `处理失败` summary. +- Add focused Main/projector/Renderer regression coverage and run the required + typecheck, lint, and production build verification. + +## Intent And Constraints + +- Preserve Pi `0.84.2` as the sole runtime and keep Pi session/wire shapes + inside Electron Main. Renderer continues to consume product-neutral + Snapshot/Patch data through the Host API. +- Full visible history must not change the compacted model context or expose + the private compaction summary. Accepted/uncertain mutations remain + non-replayed. +- Quota classification must be narrow enough not to reinterpret unrelated + `403`, `429`, or Provider failures, and raw upstream text must not leak into + Renderer state. +- The ready-for-integration progress-message task overlaps the timeline source + and test files but has no semantic conflict; keep changes localized to the + history-window and scrolling behavior. + +## Outcome + +- Pi session hydration now projects the complete active branch in chronological + order instead of dropping entries before the latest compaction boundary. + Compaction summaries remain private, and multiple durable compaction markers + reconcile against live IDs from newest to newest. +- The Renderer still mounts only the latest 120 nodes initially, automatically + prepends earlier nodes in 100-node steps near the top, and offsets + `scrollTop` by the added height so the current reading position stays fixed. +- The exact `403 token_point_balance_exhausted` / `词元点数余额不足` response is + classified as quota exhaustion, forwarded as non-retryable HTTP 402, and + projected as `CODING_PROVIDER_QUOTA_EXHAUSTED` with the safe message + `词元点数余额不足,请充值后重试。`. +- Live failures and persisted Pi assistant failures share the same redacting + classifier. Reopened conversations restore only the safe Provider notice; + raw upstream request IDs are not copied into the public Snapshot. +- The collapsed process summary displays the actionable public failure message + for both live runs and reopened persisted failures instead of generic + `处理失败`. + +## Verification + +- `pnpm exec vitest run tests/unit/pi-session-projector.test.ts tests/unit/coding-conversation-timeline.test.tsx tests/unit/ai-proxy-routes.test.ts tests/unit/pi-event-projector.test.ts` + - Passed: 4 files, 45 tests. +- `pnpm run typecheck` + - Passed. +- `pnpm run lint:check` + - Passed with 0 errors and 5 existing warnings in `src/pages/Home/index.tsx` + and `src/pages/Makelore/index.tsx`. +- `pnpm test` + - Passed: 211 regular files / 1,730 tests plus the isolated pressure file / + 1 test; 3 tests skipped. +- `pnpm run build:vite` + - Passed for Renderer, Electron Main, Preload, and release utility worker; + existing Vite dynamic-import and chunk-size warnings remain. +- `node ./node_modules/@playwright/test/cli.js test tests/e2e/pi-coding-first-chat.spec.ts` + - Passed: 2 Electron tests, including persisted quota feedback, automatic + older-history loading, and scroll-anchor preservation. +- `git diff --check` + - Passed. + +## Follow-ups + +- Integration should account for the ready progress-message task because both + tasks intentionally touch `CodingConversationTimeline.tsx` and its unit/E2E + coverage; the edited concerns are separate. + +## Promotion Candidates + +- Target canonical documents: `README.md` and the integrated current-state + summary. +- Proposal: state that Pi compaction changes model context but does not remove + visible active-branch history, and that recognized Provider quota failures + remain actionable after reopening a conversation. +- Evidence: focused projector/Renderer/proxy tests, the 1,731-test full unit + run including pressure coverage, the production build, and the two focused + Electron E2E scenarios all pass. +- Future impact: troubleshooting and future history work should preserve the + distinction between Pi context retention and product-visible session + history, and should reuse the redacted Provider-failure projection. +- Semantic conflicts: none known; the peer progress-message task overlaps the + timeline files but not these semantics. +- Human confirmation required: no product-direction choice is known to be + required, but the integration owner must decide whether this detail belongs + in canonical user-facing documentation. diff --git a/electron/api/routes/ai-proxy.ts b/electron/api/routes/ai-proxy.ts index ce59208..3015793 100644 --- a/electron/api/routes/ai-proxy.ts +++ b/electron/api/routes/ai-proxy.ts @@ -86,7 +86,8 @@ function getForwardedOneApiStatus(status: number, bodyText: string): number { if (status === 502 && isAIGatewayUserContextMissing(bodyText)) { return 401; } - if (status === 429 && getAIGatewayErrorKind(bodyText) === 'quota_exhausted') { + if ((status === 403 || status === 429) + && getAIGatewayErrorKind(bodyText) === 'quota_exhausted') { return 402; } if (status === 429 && isAIGatewayUpstreamSaturated(bodyText)) { diff --git a/electron/coding-runtime/pi/event-projector.ts b/electron/coding-runtime/pi/event-projector.ts index 72ee270..f49e511 100644 --- a/electron/coding-runtime/pi/event-projector.ts +++ b/electron/coding-runtime/pi/event-projector.ts @@ -6,8 +6,8 @@ import type { ConversationToolNode, PublicUsage, } from '../contracts'; -import { isAIGatewayUserContextMissing } from '../../../shared/ai-gateway-error-details'; import type { PiRpcEvent } from './rpc-client'; +import { projectPiProviderFailure } from './provider-failure'; import { subagentDetailsOfResult } from '../subagent-protocol'; import { isProductToolName, @@ -45,17 +45,6 @@ function retryFailure(message: string) { }; } -function providerFailure(message: unknown) { - if (typeof message === 'string' && isAIGatewayUserContextMissing(message)) { - return { - code: 'CODING_PROVIDER_AUTH_REQUIRED' as const, - message: '模型服务身份上下文无效,请重试;若仍失败请重新登录。', - recoverable: true, - }; - } - return retryFailure('模型服务请求失败,请稍后重试。'); -} - function asRecord(value: unknown): Record | null { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record @@ -294,7 +283,7 @@ export class PiEventProjector { ...run, status: 'error', terminalReason: 'failed', - error: providerFailure(event.finalError), + error: projectPiProviderFailure(event.finalError), }, }]; } @@ -485,7 +474,7 @@ export class PiEventProjector { ...snapshot.run, status: 'error', terminalReason: 'failed', - error: providerFailure(assistant.errorMessage), + error: projectPiProviderFailure(assistant.errorMessage), }, }]; } diff --git a/electron/coding-runtime/pi/provider-failure.ts b/electron/coding-runtime/pi/provider-failure.ts new file mode 100644 index 0000000..346a5e4 --- /dev/null +++ b/electron/coding-runtime/pi/provider-failure.ts @@ -0,0 +1,25 @@ +import type { CodingRuntimePublicError } from '../contracts'; +import { isAIGatewayUserContextMissing } from '../../../shared/ai-gateway-error-details'; +import { getAIGatewayErrorKind } from '../../../shared/ai-gateway-error-kind'; + +export function projectPiProviderFailure(message: unknown): CodingRuntimePublicError { + if (typeof message === 'string' && isAIGatewayUserContextMissing(message)) { + return { + code: 'CODING_PROVIDER_AUTH_REQUIRED', + message: '模型服务身份上下文无效,请重试;若仍失败请重新登录。', + recoverable: true, + }; + } + if (typeof message === 'string' && getAIGatewayErrorKind(message) === 'quota_exhausted') { + return { + code: 'CODING_PROVIDER_QUOTA_EXHAUSTED', + message: '词元点数余额不足,请充值后重试。', + recoverable: false, + }; + } + return { + code: 'CODING_RUNTIME_START_FAILED', + message: '模型服务请求失败,请稍后重试。', + recoverable: true, + }; +} diff --git a/electron/coding-runtime/pi/session-projector.ts b/electron/coding-runtime/pi/session-projector.ts index 70c611b..b27d7a7 100644 --- a/electron/coding-runtime/pi/session-projector.ts +++ b/electron/coding-runtime/pi/session-projector.ts @@ -14,6 +14,7 @@ import type { } from './event-projector'; import { isProductToolName, productToolDetailsOfResult } from '../product-tool-protocol'; import { subagentDetailsOfResult } from '../subagent-protocol'; +import { projectPiProviderFailure } from './provider-failure'; export interface PiSessionSnapshotInput { snapshot: ConversationSnapshot; @@ -106,23 +107,6 @@ function activePath(entriesValue: unknown, leafId: unknown): SessionEntryRecord[ return path.reverse(); } -function retainedTail(path: SessionEntryRecord[]): SessionEntryRecord[] { - const compactionIndex = path.findLastIndex((entry) => entry.type === 'compaction'); - if (compactionIndex < 0) return path; - const compaction = path[compactionIndex]; - const firstKeptEntryId = compaction.firstKeptEntryId; - if (typeof firstKeptEntryId !== 'string') unreadable(); - const firstKeptIndex = path.findIndex( - (entry, index) => index < compactionIndex && entry.id === firstKeptEntryId, - ); - if (firstKeptIndex < 0) unreadable(); - return [ - compaction, - ...path.slice(firstKeptIndex, compactionIndex), - ...path.slice(compactionIndex + 1), - ]; -} - async function contentBlocks( messageId: string, content: unknown, @@ -265,6 +249,19 @@ async function projectEntries( ...(stopReason ? { stopReason } : {}), }; nodes.push(node); + if (message.stopReason === 'error') { + const failure = projectPiProviderFailure(message.errorMessage); + if (failure.code === 'CODING_PROVIDER_AUTH_REQUIRED' + || failure.code === 'CODING_PROVIDER_QUOTA_EXHAUSTED') { + nodes.push({ + kind: 'notice', + id: `${messageId}:provider-error`, + code: failure.code, + level: 'error', + message: failure.message, + }); + } + } if (!Array.isArray(message.content)) continue; for (const value of message.content) { const block = asRecord(value); @@ -310,6 +307,16 @@ function reconcileLiveIds( liveNodes: ConversationNode[], ): ConversationNode[] { const used = new Set(); + const durableCompactions = durableNodes.filter((node) => node.kind === 'compaction'); + const liveCompactions = liveNodes.filter((node) => node.kind === 'compaction'); + const compactionMatches = new Map>(); + for ( + let durableIndex = durableCompactions.length - 1, liveIndex = liveCompactions.length - 1; + durableIndex >= 0 && liveIndex >= 0; + durableIndex -= 1, liveIndex -= 1 + ) { + compactionMatches.set(durableCompactions[durableIndex].id, liveCompactions[liveIndex]); + } return durableNodes.map((node) => { if (node.kind === 'message') { const live = liveNodes.find((candidate) => candidate.kind === 'message' @@ -338,10 +345,8 @@ function reconcileLiveIds( }; } if (node.kind === 'compaction') { - const live = liveNodes.findLast( - (candidate) => candidate.kind === 'compaction' && !used.has(candidate.id), - ); - if (!live || live.kind !== 'compaction') return node; + const live = compactionMatches.get(node.id); + if (!live) return node; used.add(live.id); return { ...node, id: live.id, runId: live.runId }; } @@ -375,7 +380,7 @@ export async function projectPiSessionSnapshot( const response = asRecord(input.entries); const state = asRecord(input.state); if (!response || !state) unreadable(); - const path = retainedTail(activePath(response.entries, response.leafId)); + const path = activePath(response.entries, response.leafId); const durableNodes = await projectEntries(path, input); const nodes = reconcileLiveIds(durableNodes, input.snapshot.nodes); return { diff --git a/shared/ai-gateway-error-kind.ts b/shared/ai-gateway-error-kind.ts index d512b7d..f16ca51 100644 --- a/shared/ai-gateway-error-kind.ts +++ b/shared/ai-gateway-error-kind.ts @@ -3,8 +3,10 @@ export type AIGatewayErrorKind = 'quota_exhausted' | 'authentication_invalid'; export function isTokenBalanceExhausted(message: string | null | undefined): boolean { const normalized = message?.trim().toLowerCase() ?? ''; return normalized.includes('token_balance_exhausted') + || normalized.includes('token_point_balance_exhausted') || normalized.includes('token balance exhausted') - || normalized.includes('balance exhausted'); + || normalized.includes('balance exhausted') + || normalized.includes('词元点数余额不足'); } export function getAIGatewayErrorKind(message: string | null | undefined): AIGatewayErrorKind | null { diff --git a/shared/coding-conversation-contracts.ts b/shared/coding-conversation-contracts.ts index 111e0a9..8b39b9d 100644 --- a/shared/coding-conversation-contracts.ts +++ b/shared/coding-conversation-contracts.ts @@ -28,6 +28,7 @@ export type CodingRuntimeErrorCode = | 'CODING_RUNTIME_READY_TIMEOUT' | 'CODING_RUNTIME_PROTOCOL_ERROR' | 'CODING_PROVIDER_AUTH_REQUIRED' + | 'CODING_PROVIDER_QUOTA_EXHAUSTED' | 'CODING_MODEL_UNAVAILABLE' | 'CODING_SESSION_UNREADABLE' | 'CODING_STORAGE_WRITE_FAILED' diff --git a/shared/coding-conversation-reducer.ts b/shared/coding-conversation-reducer.ts index 985ba23..1a219dd 100644 --- a/shared/coding-conversation-reducer.ts +++ b/shared/coding-conversation-reducer.ts @@ -60,6 +60,7 @@ const ERROR_CODES = new Set([ 'CODING_RUNTIME_READY_TIMEOUT', 'CODING_RUNTIME_PROTOCOL_ERROR', 'CODING_PROVIDER_AUTH_REQUIRED', + 'CODING_PROVIDER_QUOTA_EXHAUSTED', 'CODING_MODEL_UNAVAILABLE', 'CODING_SESSION_UNREADABLE', 'CODING_STORAGE_WRITE_FAILED', diff --git a/src/pages/Chat/CodingConversationTimeline.tsx b/src/pages/Chat/CodingConversationTimeline.tsx index 2a82f2c..04af87e 100644 --- a/src/pages/Chat/CodingConversationTimeline.tsx +++ b/src/pages/Chat/CodingConversationTimeline.tsx @@ -1265,6 +1265,19 @@ function processItemIsFailed(item: ProcessItem): boolean { return item.kind === 'notice' && item.node.level === 'error'; } +function persistedProviderFailureMessage(items: ProcessItem[]): string | undefined { + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (item?.kind === 'notice' + && item.node.level === 'error' + && (item.node.code === 'CODING_PROVIDER_AUTH_REQUIRED' + || item.node.code === 'CODING_PROVIDER_QUOTA_EXHAUSTED')) { + return item.node.message; + } + } + return undefined; +} + function processGroupIsFailed( items: ProcessItem[], run: ConversationRunState, @@ -1326,10 +1339,13 @@ const ProcessGroup = memo(function ProcessGroup({ ? runningDurationLabel(run, now) : settledDurationLabel(run) : null; + const failureMessage = failed + ? (latest ? run.error?.message : undefined) ?? persistedProviderFailureMessage(items) + : undefined; const summary = active ? `处理中${duration ? ` ${duration}` : ''}` : failed - ? `处理失败${duration ? ` · ${duration}` : ''}` + ? `${failureMessage ?? '处理失败'}${duration ? ` · ${duration}` : ''}` : `已处理${duration ? ` ${duration}` : ''}`; return ( @@ -1491,11 +1507,33 @@ export const CodingConversationTimeline = memo(function CodingConversationTimeli const nodes = useCodingConversationStore(selectNodes); const run = useCodingConversationStore(selectRun); const [visibleLimit, setVisibleLimit] = useState(INITIAL_WINDOW); + const scrollRef = useRef(null); + const stickToBottomRef = useRef(true); + const prependAnchorRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null); const windowStart = Math.max(0, nodes.length - visibleLimit); const visibleNodes = nodes.slice(windowStart); const turns = useMemo(() => timelineTurns(visibleNodes), [visibleNodes]); - const scrollRef = useRef(null); - const stickToBottomRef = useRef(true); + + const loadEarlier = useCallback(() => { + if (windowStart === 0 || prependAnchorRef.current) return; + const element = scrollRef.current; + if (!element) return; + prependAnchorRef.current = { + scrollHeight: element.scrollHeight, + scrollTop: element.scrollTop, + }; + stickToBottomRef.current = false; + setVisibleLimit((current) => Math.min(nodes.length, current + WINDOW_STEP)); + }, [nodes.length, windowStart]); + + useLayoutEffect(() => { + const anchor = prependAnchorRef.current; + if (!anchor) return; + prependAnchorRef.current = null; + const element = scrollRef.current; + if (!element) return; + element.scrollTop = anchor.scrollTop + (element.scrollHeight - anchor.scrollHeight); + }, [visibleLimit]); useLayoutEffect(() => { if (!stickToBottomRef.current) return; @@ -1513,6 +1551,7 @@ export const CodingConversationTimeline = memo(function CodingConversationTimeli onScroll={(event) => { const element = event.currentTarget; stickToBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 64; + if (element.scrollTop <= 48) loadEarlier(); }} >
@@ -1521,7 +1560,7 @@ export const CodingConversationTimeline = memo(function CodingConversationTimeli type="button" variant="ghost" className="mx-auto min-h-9 rounded-lg px-4 text-xs transition-transform duration-150 ease-out active:scale-[0.96]" - onClick={() => setVisibleLimit((current) => current + WINDOW_STEP)} + onClick={loadEarlier} > 加载更早内容 diff --git a/tests/e2e/pi-coding-first-chat.spec.ts b/tests/e2e/pi-coding-first-chat.spec.ts index b8fad8d..48b662b 100644 --- a/tests/e2e/pi-coding-first-chat.spec.ts +++ b/tests/e2e/pi-coding-first-chat.spec.ts @@ -143,6 +143,12 @@ async function installCodingFirstChatHost( : null, modelResolution: featureComplete ? 'resolved' : 'required', }; + const historyConversation = { + ...conversation, + id: 'conversation-pi-history', + title: 'History and quota', + updatedAt: '2026-08-23T23:58:00.000Z', + }; const snapshot = { schemaVersion: 1, conversation: { @@ -353,6 +359,39 @@ async function installCodingFirstChatHost( error: { code: 'CODING_RUNTIME_START_FAILED', message: 'Worker stopped', recoverable: true }, }, }; + const historySnapshot = { + ...snapshot, + conversation: { + ...snapshot.conversation, + id: historyConversation.id, + title: historyConversation.title, + }, + nodes: [ + ...Array.from({ length: 150 }, (_, index) => ({ + kind: 'message', + id: `history-message-${index}`, + role: 'user', + status: 'complete', + blocks: [{ + kind: 'text', + id: `history-message-${index}:content:0`, + text: `History message ${index}`, + status: 'complete', + }], + })), + { + kind: 'notice', + id: 'history-quota-notice', + code: 'CODING_PROVIDER_QUOTA_EXHAUSTED', + level: 'error', + message: '词元点数余额不足,请充值后重试。', + }, + ], + run: { status: 'idle' }, + queue: { items: [] }, + pendingInteractions: [], + worker: { status: 'ready', generation: 1 }, + }; const respond = (json: unknown, status = 200) => ({ ok: true, data: { status, ok: status >= 200 && status < 300, json }, @@ -453,7 +492,7 @@ async function installCodingFirstChatHost( if (path === `/api/coding/projects/conversations?projectId=${project.id}`) { return respond({ conversations: featureComplete - ? [conversation, secondConversation] + ? [conversation, secondConversation, historyConversation] : state.conversationCreated ? [conversation] : [], @@ -478,6 +517,9 @@ async function installCodingFirstChatHost( if (path === `/api/coding/conversations/${secondConversation.id}/snapshot`) { return respond({ snapshot: secondSnapshot }); } + if (path === `/api/coding/conversations/${historyConversation.id}/snapshot`) { + return respond({ snapshot: historySnapshot }); + } if (path === `/api/coding/conversations/${conversation.id}/prompt` && method === 'POST') { return respond({ acceptance: { @@ -890,6 +932,29 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model await expect(page.getByRole('textbox')).toHaveValue(''); await page.getByRole('button', { name: '恢复' }).click(); + await builderConversations.getByRole('button', { name: 'History and quota' }).click(); + const historyProcess = page.getByTestId('coding-process-group'); + await expect(historyProcess.locator('summary').first()) + .toContainText('词元点数余额不足,请充值后重试。'); + await expect(page.getByText('History message 0')).toHaveCount(0); + await expect(page.getByText('History message 31')).toHaveCount(1); + const historyTimeline = page.getByTestId('coding-conversation-timeline'); + const beforeHeight = await historyTimeline.evaluate((element) => { + element.scrollTop = 0; + const height = element.scrollHeight; + element.dispatchEvent(new Event('scroll', { bubbles: true })); + return height; + }); + await expect(page.getByText('History message 0')).toHaveCount(1); + const anchoredScroll = await historyTimeline.evaluate((element) => ({ + scrollHeight: element.scrollHeight, + scrollTop: element.scrollTop, + })); + expect(anchoredScroll.scrollTop).toBeGreaterThan(0); + expect(Math.abs( + anchoredScroll.scrollTop - (anchoredScroll.scrollHeight - beforeHeight), + )).toBeLessThanOrEqual(2); + await expect(page.getByText(/编程工具|分享|取消分享|回滚|恢复回滚|待办|全局运行时/)).toHaveCount(0); const state = await readState(electronApp); expect(state.captured.some((request) => request.path.endsWith('/model') && request.method === 'POST')).toBe(true); diff --git a/tests/unit/ai-proxy-routes.test.ts b/tests/unit/ai-proxy-routes.test.ts index dfa98a6..4b70a71 100644 --- a/tests/unit/ai-proxy-routes.test.ts +++ b/tests/unit/ai-proxy-routes.test.ts @@ -274,6 +274,40 @@ describe('ai proxy routes', () => { expect(response.body()).toBe(JSON.stringify({ error: 'user quota is not enough' })); }); + it('maps the observed token-point balance 403 to a non-retryable quota response', async () => { + seedWorksSquareAIGatewayCredential({ + accessToken: 'ws-ai-token', + expiresIn: 3600, + oneApiBaseUrl: 'https://one-api.example.com/v1', + }); + const body = JSON.stringify({ + error: { + message: '词元点数余额不足', + code: 'token_point_balance_exhausted', + type: 'one_api_error', + }, + }); + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response(body, { + status: 403, + headers: { 'content-type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + const response = createResponse(); + + await handleAiProxyRoutes( + createRequest('POST', { model: 'deepseek-v4-flash', messages: [] }), + response.res, + new URL('http://127.0.0.1:13210/api/ai-proxy/v1/chat/completions'), + {} as never, + ); + + expect(fetchMock).toHaveBeenCalledOnce(); + expect(response.statusCode).toBe(402); + expect(response.body()).toBe(body); + }); + it('maps rolling-window quota exhaustion to a non-retryable response status', async () => { seedWorksSquareAIGatewayCredential({ accessToken: 'ws-ai-token', diff --git a/tests/unit/coding-conversation-timeline.test.tsx b/tests/unit/coding-conversation-timeline.test.tsx index 6bef893..9d82ca2 100644 --- a/tests/unit/coding-conversation-timeline.test.tsx +++ b/tests/unit/coding-conversation-timeline.test.tsx @@ -631,7 +631,83 @@ describe('CodingConversationTimeline', () => { .toBeVisible(); }); - it('windows long timelines and loads earlier nodes only on demand', async () => { + it('shows an actionable non-retryable quota failure in the process summary', async () => { + const { codingConversationStore } = await import('@/stores/coding-conversations'); + const { CodingConversationTimeline } = await import( + '@/pages/Chat/CodingConversationTimeline' + ); + const base = createProductSnapshot('conversation-quota-failure', 1); + const snapshot = { + ...base, + run: { + status: 'idle' as const, + runId: 'run-quota-failure', + startedAt: 1_000, + settledAt: 6_000, + terminalReason: 'failed' as const, + error: { + code: 'CODING_PROVIDER_QUOTA_EXHAUSTED' as const, + message: '词元点数余额不足,请充值后重试。', + recoverable: false, + }, + }, + nodes: [{ + kind: 'tool' as const, + id: 'tool-quota-failure', + toolCallId: 'call-quota-failure', + toolName: 'bash', + title: '执行命令', + inputText: '', + status: 'error' as const, + output: [], + }], + }; + codingConversationStore.getState().applySnapshotEvent({ + type: 'snapshot', + conversationId: 'conversation-quota-failure', + workerGeneration: 1, + seq: snapshot.cursor.seq, + snapshot, + }); + + render(); + + expect(within(screen.getByTestId('coding-process-group')) + .getByText('词元点数余额不足,请充值后重试。 · 5 秒')).toBeVisible(); + }); + + it('shows an actionable persisted Provider failure after reopening a Conversation', async () => { + const { codingConversationStore } = await import('@/stores/coding-conversations'); + const { CodingConversationTimeline } = await import( + '@/pages/Chat/CodingConversationTimeline' + ); + const base = createProductSnapshot('conversation-persisted-quota-failure', 1); + const snapshot = { + ...base, + run: { status: 'idle' as const }, + nodes: [{ + kind: 'notice' as const, + id: 'persisted-quota-notice', + code: 'CODING_PROVIDER_QUOTA_EXHAUSTED', + level: 'error' as const, + message: '词元点数余额不足,请充值后重试。', + }], + }; + codingConversationStore.getState().applySnapshotEvent({ + type: 'snapshot', + conversationId: 'conversation-persisted-quota-failure', + workerGeneration: 1, + seq: snapshot.cursor.seq, + snapshot, + }); + + render(); + + expect(screen.getByTestId('coding-process-group').querySelector('summary')) + .toHaveTextContent('词元点数余额不足,请充值后重试。'); + }); + + it('windows long timelines, loads near the top, and preserves the scroll anchor', async () => { const { codingConversationStore } = await import('@/stores/coding-conversations'); const { CodingConversationTimeline } = await import( '@/pages/Chat/CodingConversationTimeline' @@ -659,8 +735,17 @@ describe('CodingConversationTimeline', () => { expect(screen.queryByText('Notice 0')).not.toBeInTheDocument(); expect(screen.getByText('Notice 30')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '加载更早内容' })); - expect(screen.getByText('Notice 0')).toBeInTheDocument(); + const timeline = screen.getByTestId('coding-conversation-timeline'); + Object.defineProperty(timeline, 'scrollHeight', { + configurable: true, + get: () => screen.queryByText('Notice 0') ? 1_600 : 1_000, + }); + timeline.scrollTop = 0; + + fireEvent.scroll(timeline); + + await waitFor(() => expect(screen.getByText('Notice 0')).toBeInTheDocument()); + expect(timeline.scrollTop).toBe(600); }); it('does not commit the selected timeline when a hidden Conversation streams', async () => { diff --git a/tests/unit/pi-event-projector.test.ts b/tests/unit/pi-event-projector.test.ts index 071d728..118bbed 100644 --- a/tests/unit/pi-event-projector.test.ts +++ b/tests/unit/pi-event-projector.test.ts @@ -472,6 +472,40 @@ describe('Pi event projector', () => { expect(JSON.stringify(snapshot)).not.toContain('secret request detail'); }); + it('projects token-point balance exhaustion as a non-retryable Provider quota failure', async () => { + const projector = new PiEventProjector({ createId: () => 'provider-quota-error-a' }); + let snapshot = emptySnapshot(); + const providerFailure = { + role: 'assistant', + content: [], + stopReason: 'error', + errorMessage: '403: {"error":{"message":"词元点数余额不足","code":"token_point_balance_exhausted","request_id":"secret-request"}}', + timestamp: 10, + }; + + snapshot = apply(snapshot, await projector.project(snapshot, { + type: 'message_end', + message: providerFailure, + })); + snapshot = apply(snapshot, await projector.project(snapshot, { + type: 'agent_end', + messages: [providerFailure], + willRetry: false, + })); + snapshot = apply(snapshot, await projector.project(snapshot, { type: 'agent_settled' })); + + expect(snapshot.run).toMatchObject({ + status: 'idle', + terminalReason: 'failed', + error: { + code: 'CODING_PROVIDER_QUOTA_EXHAUSTED', + message: '词元点数余额不足,请充值后重试。', + recoverable: false, + }, + }); + expect(JSON.stringify(snapshot)).not.toContain('secret-request'); + }); + it('keeps an exhausted Works user-context retry redacted and Provider-owned', async () => { const projector = new PiEventProjector({ createId: () => 'provider-retry-error-a' }); let snapshot = emptySnapshot(); diff --git a/tests/unit/pi-session-projector.test.ts b/tests/unit/pi-session-projector.test.ts index 31703d1..69c4d7e 100644 --- a/tests/unit/pi-session-projector.test.ts +++ b/tests/unit/pi-session-projector.test.ts @@ -242,7 +242,7 @@ describe('Pi session projector', () => { expect(snapshot.cursor).toEqual({ workerGeneration: 1, seq: 7 }); }); - it('applies retained-tail compaction and reconciles durable entries without replacing live IDs', async () => { + it('projects the full active history around compaction and reconciles durable entries without replacing live IDs', async () => { const live: ConversationSnapshot = { ...baseSnapshot(), nodes: [ @@ -371,10 +371,11 @@ describe('Pi session projector', () => { }); expect(snapshot.nodes.map(({ kind, id }) => ({ kind, id }))).toEqual([ - { kind: 'compaction', id: 'live-compaction-a' }, + { kind: 'message', id: 'entry:entry-old-user' }, { kind: 'message', id: 'live-user-a' }, { kind: 'message', id: 'live-assistant-a' }, { kind: 'tool', id: 'live-tool-a' }, + { kind: 'compaction', id: 'live-compaction-a' }, { kind: 'message', id: 'entry:entry-after-user' }, ]); expect(snapshot.nodes).toContainEqual(expect.objectContaining({ @@ -401,10 +402,55 @@ describe('Pi session projector', () => { compaction: 'idle', recalculating: true, }); - expect(JSON.stringify(snapshot)).not.toContain('Summarized old question'); + expect(JSON.stringify(snapshot)).toContain('Summarized old question'); expect(JSON.stringify(snapshot)).not.toContain('summary must stay hidden'); }); + it('restores persisted Provider quota failures as safe actionable notices', async () => { + const snapshot = await projectPiSessionSnapshot({ + snapshot: baseSnapshot(), + workerGeneration: 2, + state: { sessionId: 'pi-session-a', isStreaming: false, isCompacting: false }, + entries: { + leafId: 'entry-quota-error', + entries: [ + { + type: 'message', + id: 'entry-quota-user', + parentId: null, + message: { role: 'user', content: 'Continue', timestamp: 1 }, + }, + { + type: 'message', + id: 'entry-quota-error', + parentId: 'entry-quota-user', + message: { + role: 'assistant', + content: [], + stopReason: 'error', + errorMessage: '403: {"message":"词元点数余额不足","code":"token_point_balance_exhausted","request_id":"secret-request"}', + timestamp: 2, + }, + }, + ], + }, + }); + + expect(snapshot.nodes).toContainEqual(expect.objectContaining({ + kind: 'message', + id: 'entry:entry-quota-error', + status: 'error', + })); + expect(snapshot.nodes).toContainEqual({ + kind: 'notice', + id: 'entry:entry-quota-error:provider-error', + code: 'CODING_PROVIDER_QUOTA_EXHAUSTED', + level: 'error', + message: '词元点数余额不足,请充值后重试。', + }); + expect(JSON.stringify(snapshot)).not.toContain('secret-request'); + }); + it('projects persisted images through attachment storage without retaining base64', async () => { const rawImage = 'A'.repeat(1024 * 1024); const projected = await projectPiSessionSnapshot({ From 8d878ebd14d0fc7a3e63584170d482892abfccfc Mon Sep 17 00:00:00 2001 From: inman Date: Tue, 1 Sep 2026 11:46:04 +0800 Subject: [PATCH 09/30] feat: preserve assistant progress previews --- ...0260831-show-progress-messages-a14f9c2d.md | 84 +++++++++++++++++++ src/pages/Chat/CodingConversationTimeline.tsx | 29 +++---- tests/e2e/pi-coding-first-chat.spec.ts | 20 ++--- .../coding-conversation-timeline.test.tsx | 58 +++++++++---- 4 files changed, 150 insertions(+), 41 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260831-show-progress-messages-a14f9c2d.md diff --git a/.project-docs/30-worklog/tasks/20260831-show-progress-messages-a14f9c2d.md b/.project-docs/30-worklog/tasks/20260831-show-progress-messages-a14f9c2d.md new file mode 100644 index 0000000..cfef58e --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260831-show-progress-messages-a14f9c2d.md @@ -0,0 +1,84 @@ +# Task: Show assistant progress messages + +## Identity + +- Task ID: 20260831-show-progress-messages-a14f9c2d +- Mode: Feature +- Branch: codex/20260831-show-progress-messages-a14f9c2d-show-progress-messages-a14f9c2d +- Worktree: /Users/inmanx/Documents/makelore-show-progress-messages-a14f9c2d +- Base commit: 38f85f6b5e4dc4e2c5e5b9f8f4506554cfd578f5 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Identify the protocol and UI classification of assistant text emitted before + or between tool calls, using the supplied screenshot only as a visual example. +- Verify that this stage/progress narration is projected and rendered in the + current Makelore Code timeline without exposing Pi-private wire types. +- Preserve per-item expand/collapse after a stage explanation has completed, + including after the enclosing turn has settled. +- Make every collapsed thinking, stage-explanation, and tool-output preview + start from its first visible block and line, including horizontally + overflowing text. + +## Intent And Constraints + +- Preserve the product-neutral `ConversationMessageNode` / Snapshot / Patch + contract and the Main-owned Pi boundary. +- Keep private reasoning distinct from user-visible assistant narration and + tool execution output. +- Do not modify the occupied primary worktree or the separate running + development worktree. + +## Outcome + +- Confirmed the raw message is an ordinary Pi assistant message whose turn ends + with `stopReason: toolUse`; Main normalizes it to an assistant + `ConversationMessageNode` with `stopReason: tool-use`. +- Confirmed the Renderer semantically projects non-final assistant text as + `assistant-commentary` / “过程说明”, rather than treating it as hidden + thinking or as a tool node. +- Confirmed active process groups open automatically and show a compact + one-line stage explanation with an expand control; settled process groups + retain the explanation behind their collapsed process summary while the + final assistant answer remains visible. +- Added explicit regression coverage proving a completed stage explanation + remains collapsed to its first line by default, retains its own expand + control, reveals the complete Markdown body, and can be collapsed again. +- Changed collapsed process preview selection to use the first displayable + block and first non-empty line for thinking, assistant commentary, and tool + output. Long lines retain their beginning and use a trailing ellipsis. +- Kept every collapsed preview viewport at horizontal offset zero on content + and size changes. Tail-only streaming updates no longer replace or replay an + unchanged head preview, while expanded content remains complete. + +## Verification + +- Installed the existing lockfile with pinned pnpm `10.33.4`; 1,015 packages + were reused from the local store and no manifest or lockfile changed. +- Focused Vitest passed: 3 files / 28 tests covering live Pi event projection, + durable session hydration, and Coding timeline process/commentary rendering. +- Follow-up focused Vitest passed: 1 file / 9 tests, including completed + commentary expand/collapse and stable head-preview assertions across + streaming updates. +- `pnpm run typecheck`: passed. +- Scoped ESLint for the changed Renderer, unit, and Electron E2E files: passed. +- `pnpm run build:vite`: passed; only the repository's existing dynamic-import + and bundle-size warnings were emitted. +- Focused Electron Playwright E2E passed: 1 file / 2 tests, including first-line + thinking display, head truncation, and a measured tool-preview + `scrollLeft` of zero. +- Inspected `electron/coding-runtime/pi/event-projector.ts`, + `electron/coding-runtime/pi/session-projector.ts`, + `shared/coding-conversation-contracts.ts`, and + `src/pages/Chat/CodingConversationTimeline.tsx` across the complete + classification path. + +## Follow-ups + +- None. + +## Promotion Candidates + +- None recorded. diff --git a/src/pages/Chat/CodingConversationTimeline.tsx b/src/pages/Chat/CodingConversationTimeline.tsx index 2a82f2c..e4e407f 100644 --- a/src/pages/Chat/CodingConversationTimeline.tsx +++ b/src/pages/Chat/CodingConversationTimeline.tsx @@ -72,7 +72,6 @@ const WINDOW_STEP = 100; const LONG_OUTPUT_CHARS = 4_000; const STREAMING_THINKING_WINDOW_CHARS = 16_000; const PROGRESS_PREVIEW_MAX_CHARS = 360; -const PROGRESS_ROLL_CHAR_STEP = 6; const NODE_STATUS_LABELS: Record = { declared: '已声明', @@ -652,19 +651,19 @@ function toolActivityIcon(node: ConversationToolNode) { return