From dcce55eb8e069398333878f61c5e7b320dfdf1f5 Mon Sep 17 00:00:00 2001 From: "jianyu.zhang" <3193941960@qq.com> Date: Thu, 15 Oct 2026 10:53:28 +0800 Subject: [PATCH 1/8] feat(plugins): add Windows icons to cloud plugin collections --- apps/desktop/scripts/plugin-package-icons.mjs | 23 +++++ apps/desktop/scripts/plugin-package-icons.ps1 | 62 ++++++++++++++ docs/plugin-packages.md | 2 + evals/flows/plugin-package-file-icon.flow.mjs | 83 +++++++++++++++++++ scripts/package-plugin-collection.mjs | 5 ++ scripts/package-plugin-collection.test.mjs | 53 ++++++++++++ 6 files changed, 228 insertions(+) create mode 100644 apps/desktop/scripts/plugin-package-icons.mjs create mode 100644 apps/desktop/scripts/plugin-package-icons.ps1 create mode 100644 evals/flows/plugin-package-file-icon.flow.mjs diff --git a/apps/desktop/scripts/plugin-package-icons.mjs b/apps/desktop/scripts/plugin-package-icons.mjs new file mode 100644 index 000000000..9940fbf38 --- /dev/null +++ b/apps/desktop/scripts/plugin-package-icons.mjs @@ -0,0 +1,23 @@ +import { createRequire } from "node:module"; +import { readFile } from "node:fs/promises"; +import { encodeWindowsIcon, WINDOWS_ICON_SIZES } from "../electron/brand-icon-windows.mjs"; + +const require = createRequire(import.meta.url); +const sharp = require("sharp"); + +// Transport-only assets. Never modify the signed plugin archives for Explorer. +export async function pluginPackageIconFiles() { + const source = await readFile(new URL("../../app/public/sidebar-icon/plugin.svg", import.meta.url)); + const entries = await Promise.all(WINDOWS_ICON_SIZES.map(async size => ({ + size, png: await sharp(source, { density: 96 * size / 16 }).resize(size, size).png().toBuffer(), + }))); + const files = new Map([ + ["plugin-package.ico", encodeWindowsIcon(entries)], + ["plugin-package-icons.ps1", await readFile(new URL("./plugin-package-icons.ps1", import.meta.url))], + ]); + for (const [name, action] of [["Enable-plugin-icons.cmd", "Enable"], ["Restore-plugin-icons.cmd", "Restore"]]) { + files.set(name, Buffer.from(`@echo off\r\npowershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0plugin-package-icons.ps1" -Action ${action}\r\nif errorlevel 1 (\r\n echo [FAILED] Plugin file icons could not be changed.\r\n pause\r\n exit /b 1\r\n)\r\necho [OK] Refresh the folder to see the updated file icons.\r\npause\r\n`)); + } + files.set("README-icons.txt", Buffer.from("Windows 插件安装包图标\r\n\r\n解压整个集合,然后双击 Enable-plugin-icons.cmd,只需运行一次,无需管理员权限。\r\n所有 .ipollowork-plugin 安装包会使用软件中的拼图图标。若文件夹已打开,请刷新。\r\n图标复制到本机固定目录,之后移动插件包或删除此集合都不会影响图标。\r\n需要还原时双击 Restore-plugin-icons.cmd;原有文件打开方式保持不变。\r\n其他电脑也需运行一次启用脚本;仅上传插件包到云端不会自动更改 Windows 图标。\r\n上传云端时只选择 .ipollowork-plugin 文件,图标和脚本无需上传。\r\n安装包内容、签名、版本及 SHA-256 不受此设置影响。\r\n", "utf8")); + return files; +} diff --git a/apps/desktop/scripts/plugin-package-icons.ps1 b/apps/desktop/scripts/plugin-package-icons.ps1 new file mode 100644 index 000000000..2929a5c8a --- /dev/null +++ b/apps/desktop/scripts/plugin-package-icons.ps1 @@ -0,0 +1,62 @@ +param( + [ValidateSet('Enable', 'Restore')][string]$Action = 'Enable', + [string]$IconPath = (Join-Path $PSScriptRoot 'plugin-package.ico'), + [string]$StateDirectory = (Join-Path $env:LOCALAPPDATA 'iPolloWork\file-icons'), + # An isolated HKCU root lets integration tests leave real associations alone. + [string]$ClassesRoot = 'Software\Classes' +) +$ErrorActionPreference = 'Stop' +$registryPath = "$ClassesRoot\.ipollowork-plugin\DefaultIcon" +$installedIcon = Join-Path $StateDirectory 'plugin-package.ico' +$iconValue = '"' + $installedIcon + '",0' +$backupPath = Join-Path $StateDirectory 'plugin-package-icon-backup.json' +$registry = [Microsoft.Win32.Registry]::CurrentUser +$key = $registry.OpenSubKey($registryPath, $true) +try { + $backup = if (Test-Path -LiteralPath $backupPath) { Get-Content -LiteralPath $backupPath -Raw | ConvertFrom-Json } else { $null } + if ($backup -and ($backup.registryPath -ne $registryPath -or $backup.iconValue -ne $iconValue)) { + throw 'Icon backup belongs to a different registration.' + } + if ($Action -eq 'Enable') { + $source = [IO.File]::ReadAllBytes($IconPath) + if ($source.Length -lt 6 -or [BitConverter]::ToUInt32($source, 0) -ne 65536 -or [BitConverter]::ToUInt16($source, 4) -eq 0) { + throw 'The plugin icon is not a valid ICO file.' + } + [IO.Directory]::CreateDirectory($StateDirectory) | Out-Null + if (-not $backup) { + $exists = $key -and ($key.GetValueNames() -contains '') + $kind = if ($exists) { $key.GetValueKind('').ToString() } else { 'String' } + if ($kind -notin @('String', 'ExpandString')) { throw 'Unexpected existing icon value type; nothing was changed.' } + $value = if ($exists) { $key.GetValue('', $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) } else { $null } + $backup = @{ registryPath = $registryPath; iconValue = $iconValue; exists = [bool]$exists; kind = $kind; value = $value } + [IO.File]::WriteAllText($backupPath, ($backup | ConvertTo-Json)) + } + [IO.File]::WriteAllBytes($installedIcon, $source) + if (-not $key) { $key = $registry.CreateSubKey($registryPath) } + $key.SetValue('', $iconValue, [Microsoft.Win32.RegistryValueKind]::String) + } elseif ($backup) { + if ($key -and $key.GetValue('') -ne $iconValue) { throw 'Another application changed the icon; its setting was preserved.' } + if ($backup.exists) { + if (-not $key) { $key = $registry.CreateSubKey($registryPath) } + $key.SetValue('', $backup.value, [Enum]::Parse([Microsoft.Win32.RegistryValueKind], $backup.kind)) + } elseif ($key) { + $key.DeleteValue('', $false) + if ($key.ValueCount -eq 0 -and $key.SubKeyCount -eq 0) { + $key.Dispose(); $key = $null + $registry.DeleteSubKey($registryPath, $false) + } + } + Remove-Item -LiteralPath $backupPath, $installedIcon -Force + } +} finally { if ($key) { $key.Dispose() } } + +# Notify Explorer without restarting it or changing the file open command. +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +public static class PluginIconShell { + [DllImport("shell32.dll")] public static extern void SHChangeNotify(uint eventId, uint flags, IntPtr first, IntPtr second); +} +'@ +[PluginIconShell]::SHChangeNotify(0x08000000, 0, [IntPtr]::Zero, [IntPtr]::Zero) +@{ action = $Action; icon = $installedIcon; registryPath = $registryPath } | ConvertTo-Json -Compress diff --git a/docs/plugin-packages.md b/docs/plugin-packages.md index 757de29ce..988a7f49d 100644 --- a/docs/plugin-packages.md +++ b/docs/plugin-packages.md @@ -308,6 +308,8 @@ pnpm plugins:package --plugin figma --inventory The default output is `.ipollowork-dev/plugin-releases//`. Each ready release contains individual `.ipollowork-plugin` files, `collection.json`, `SHA256SUMS`, `plugins-collection.zip` and its SHA-256 sidecar. The outer ZIP is for transport; upload the individual plugin files through the existing cloud import. An existing output directory is rejected. `collection.json` is written only after every signed package passes the real archive parser, host signature policy, identity check and original business-file hash comparison. Build/sign/validation failures stop the command and do not produce a ready collection or transport ZIP. +The collection also contains `plugin-package.ico`, generated from the app's existing sidebar plugin SVG, and Windows enable/restore scripts. After extracting the collection, run `Enable-plugin-icons.cmd` once per Windows user to assign this icon to all `.ipollowork-plugin` files. It requires no administrator access, copies the icon to `%LOCALAPPDATA%/iPolloWork/file-icons`, preserves file open commands, and backs up the previous per-user icon value for `Restore-plugin-icons.cmd`. Moving the collection afterwards is safe. Explorer may need a folder refresh. Other computers need the same one-time registration: an archive cannot carry its Windows file association to another computer. Upload only the signed plugin archives; these transport assets neither change their bytes/signatures nor belong in the cloud import. + Configure signing once in `~/.ipollowork/plugin-signing/release.json` (outside Git): ```json diff --git a/evals/flows/plugin-package-file-icon.flow.mjs b/evals/flows/plugin-package-file-icon.flow.mjs new file mode 100644 index 000000000..f41de7b69 --- /dev/null +++ b/evals/flows/plugin-package-file-icon.flow.mjs @@ -0,0 +1,83 @@ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { createRequire } from "node:module"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { pluginPackageIconFiles } from "../../apps/desktop/scripts/plugin-package-icons.mjs"; + +const require = createRequire(new URL("../../apps/desktop/package.json", import.meta.url)); +const sharp = require("sharp"); +const JSZip = createRequire(new URL("../../apps/app/package.json", import.meta.url))("jszip"); +const digest = bytes => createHash("sha256").update(bytes).digest("hex"); + +export default { + id: "plugin-package-file-icon", title: "Windows plugin archive icons preserve signed cloud packages", kind: "internal", requiresApp: false, + steps: [{ name: "Windows returns the app's plugin icon for every unchanged signed archive", run: async ctx => { + await ctx.prove("All twenty signed archives display the existing app plugin icon through the real Windows Shell", { + voiceover: "启用后,Windows 文件夹中的插件安装包统一显示软件里的拼图图标;二十个安装包的内容和签名保持原样。", + assert: async () => { + ctx.assert(process.platform === "win32", "Native Windows Shell proof requires Windows"); + const folder = process.env.IPOLLOWORK_PLUGIN_RELEASE_PROOF_DIR; + ctx.assert(Boolean(folder), "Set IPOLLOWORK_PLUGIN_RELEASE_PROOF_DIR to the extracted collection after enabling icons"); + const collection = JSON.parse(await readFile(join(folder, "collection.json"), "utf8")); + ctx.assert(collection.signed === true && collection.plugins.length === 20, "Twenty signed release packages are present"); + const zipBytes = await readFile(join(folder, "plugins-collection.zip")); + const zip = await JSZip.loadAsync(zipBytes); + ctx.assert((await readFile(join(folder, "plugins-collection.zip.sha256"), "utf8")).startsWith(digest(zipBytes) + " "), "Transport ZIP matches its digest"); + for (const plugin of collection.plugins) { + ctx.assert(digest(await readFile(join(folder, plugin.artifact.file))) === plugin.artifact.sha256, "Signed file bytes preserved: " + plugin.pluginId); + ctx.assert(digest(await zip.file(plugin.artifact.file).async("nodebuffer")) === plugin.artifact.sha256, "Transport preserves signed file bytes: " + plugin.pluginId); + } + const assets = await pluginPackageIconFiles(); + for (const [name, bytes] of assets) { + ctx.assert(bytes.equals(await readFile(join(folder, name))), "Current icon tooling delivered: " + name); + ctx.assert(bytes.equals(await zip.file(name).async("nodebuffer")), "Icon tooling included in transport: " + name); + } + const ico = assets.get("plugin-package.ico"); + const entry = 6 + 2 * 16; // The existing ICO encoder's 32px representation. + const offset = ico.readUInt32LE(entry + 12); + const expected = await sharp(ico.subarray(offset, offset + ico.readUInt32LE(entry + 8))).raw().toBuffer(); + const native = spawnSync("powershell.exe", ["-NoProfile", "-Command", String.raw` +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Drawing +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +public static class FileIconWitness { + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct Info { + public IntPtr icon; public int index; public uint attributes; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string name; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)] public string type; + } + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + public static extern IntPtr SHGetFileInfo(string path, uint attributes, ref Info info, uint size, uint flags); + [DllImport("user32.dll")] public static extern bool DestroyIcon(IntPtr icon); +} +'@ +$results = foreach ($file in Get-ChildItem -LiteralPath $env:IPOLLOWORK_PLUGIN_RELEASE_PROOF_DIR -Filter '*.ipollowork-plugin') { + $info = New-Object FileIconWitness+Info + $status = [FileIconWitness]::SHGetFileInfo($file.FullName, 0, [ref]$info, [Runtime.InteropServices.Marshal]::SizeOf($info), 0x100) + if ($status -eq [IntPtr]::Zero -or $info.icon -eq [IntPtr]::Zero) { throw "No native icon: $($file.Name)" } + $icon = [Drawing.Icon]::FromHandle($info.icon); $bitmap = $icon.ToBitmap(); $stream = New-Object IO.MemoryStream + try { + $bitmap.Save($stream, [Drawing.Imaging.ImageFormat]::Png) + @{ file = $file.Name; png = [Convert]::ToBase64String($stream.ToArray()) } + } finally { $stream.Dispose(); $bitmap.Dispose(); $icon.Dispose(); [FileIconWitness]::DestroyIcon($info.icon) | Out-Null } +} +$results | ConvertTo-Json -Compress +`], { encoding: "utf8", windowsHide: true, timeout: 30000, maxBuffer: 1024 * 1024 }); + ctx.assert(native.status === 0, "Native Shell extraction succeeds: " + native.stderr); + const icons = JSON.parse(native.stdout); + ctx.assert(icons.length === 20, "Windows returned icons for all twenty archives"); + for (const icon of icons) { + const actual = await sharp(Buffer.from(icon.png, "base64")).raw().toBuffer(); + // Windows premultiplied-alpha conversion may round a channel by one. + ctx.assert(actual.length === expected.length && actual.every((value, index) => Math.abs(value - expected[index]) <= 1), "Native file icon matches app plugin SVG: " + icon.file); + } + ctx.output("Windows Shell witness", JSON.stringify({ files: icons.map(icon => icon.file), nativeIconsMatchAppPluginIcon: true, signedArchiveBytesPreserved: true, transportSha256: digest(zipBytes) }, null, 2)); + ctx.recordEvidence({ type: "assertion", status: "passed", assertion: "Twenty native Shell icons match the app plugin SVG, and all twenty signed archive digests remain unchanged" }); + }, + }); + } }], +}; diff --git a/scripts/package-plugin-collection.mjs b/scripts/package-plugin-collection.mjs index 125c76492..ee439110d 100644 --- a/scripts/package-plugin-collection.mjs +++ b/scripts/package-plugin-collection.mjs @@ -10,6 +10,7 @@ import { catalogPluginPackageIds, resolveBundledPluginPackageRoot } from "../app import { previewPluginPackage, assertPluginPackageSafeForImport } from "../apps/server/src/plugin-package-lifecycle.ts"; import { withMaterializedPluginPackageUpload } from "../apps/server/src/plugin-package-upload.ts"; import { readPluginPackageArchive } from "../apps/app/src/app/lib/plugin-package-archive.ts"; +import { pluginPackageIconFiles } from "../apps/desktop/scripts/plugin-package-icons.mjs"; const repo = fileURLToPath(new URL("../", import.meta.url)); const requireFromApp = createRequire(new URL("../apps/app/package.json", import.meta.url)); @@ -128,6 +129,10 @@ try { const collection = JSON.stringify({ schemaVersion: 1, signed: !inventoryOnly, cloudPublished: false, plugins: entries }, null, 2) + "\n"; await writeFile(join(output, "collection.json"), collection); if (!inventoryOnly) { + for (const [file, bytes] of await pluginPackageIconFiles()) { + await writeFile(join(output, file), bytes); + zip.file(file, bytes); + } const sums = entries.map(entry => entry.artifact.sha256 + " " + entry.artifact.file).join("\n") + "\n"; await writeFile(join(output, "SHA256SUMS"), sums); zip.file("collection.json", collection); zip.file("SHA256SUMS", sums); diff --git a/scripts/package-plugin-collection.test.mjs b/scripts/package-plugin-collection.test.mjs index d5fa61fdc..4b243feb2 100644 --- a/scripts/package-plugin-collection.test.mjs +++ b/scripts/package-plugin-collection.test.mjs @@ -6,6 +6,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; +import { pluginPackageIconFiles } from "../apps/desktop/scripts/plugin-package-icons.mjs"; const repo = fileURLToPath(new URL("../", import.meta.url)); const script = fileURLToPath(new URL("./package-plugin-collection.mjs", import.meta.url)); function run(args) { return spawnSync("bun", [script, ...args], { cwd: repo, encoding: "utf8", windowsHide: true, timeout: 60000 }); } @@ -51,3 +52,55 @@ test("an untrusted signing key cannot produce a ready cloud collection", async ( await assert.rejects(readFile(join(output, "plugins-collection.zip")), { code: "ENOENT" }); } finally { await rm(root, { recursive: true, force: true }); } }); + +test("collection includes a multi-resolution plugin icon and portable Windows enable/restore tools", async () => { + const files = await pluginPackageIconFiles(); + const ico = files.get("plugin-package.ico"); + assert.equal(ico.readUInt32LE(0), 65536); + assert.equal(ico.readUInt16LE(4), 7); + for (let index = 0; index < 7; index++) { + const offset = ico.readUInt32LE(6 + index * 16 + 12); + assert.equal(ico.subarray(offset, offset + 8).toString("hex"), "89504e470d0a1a0a"); + } + assert.match(files.get("Enable-plugin-icons.cmd").toString(), /%~dp0plugin-package-icons\.ps1.*-Action Enable/); + assert.match(files.get("Restore-plugin-icons.cmd").toString(), /-Action Restore/); + assert.ok(files.has("plugin-package-icons.ps1")); + assert.ok(files.has("README-icons.txt")); +}); + +test("Windows icon registration is repeatable and restores the original icon without changing open commands", { skip: process.platform !== "win32" }, async () => { + const root = await mkdtemp(join(tmpdir(), "ipollowork-icon-test-")); + const classes = `Software\\iPolloWorkIconTests\\${process.pid}`; + const script = join(repo, "apps/desktop/scripts/plugin-package-icons.ps1"); + const icon = join(root, "插件 package.ico"); + const state = join(root, "icon state"); + const env = { ...process.env, ICON_TEST_ROOT: classes }; + function ps(args) { + const result = spawnSync("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", ...args], { env, encoding: "utf8", windowsHide: true, timeout: 30000 }); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); + } + function action(value) { ps(["-File", script, "-Action", value, "-IconPath", icon, "-StateDirectory", state, "-ClassesRoot", classes]); } + function query() { + return JSON.parse(ps(["-Command", "$r = [Microsoft.Win32.Registry]::CurrentUser; $k = $r.OpenSubKey($env:ICON_TEST_ROOT + '\\.ipollowork-plugin\\DefaultIcon'); $o = $r.OpenSubKey($env:ICON_TEST_ROOT + '\\.ipollowork-plugin\\shell\\open\\command'); @{ icon = $(if ($k) { $k.GetValue('', $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) } else { $null }); kind = $(if ($k) { $k.GetValueKind('').ToString() } else { $null }); command = $o.GetValue('') } | ConvertTo-Json -Compress; if ($k) { $k.Dispose() }; $o.Dispose()"])); + } + try { + await writeFile(icon, (await pluginPackageIconFiles()).get("plugin-package.ico")); + ps(["-Command", "$r = [Microsoft.Win32.Registry]::CurrentUser; $k = $r.CreateSubKey($env:ICON_TEST_ROOT + '\\.ipollowork-plugin\\DefaultIcon'); $k.SetValue('', '%SystemRoot%\\old.ico,2', [Microsoft.Win32.RegistryValueKind]::ExpandString); $k.Dispose(); $k = $r.CreateSubKey($env:ICON_TEST_ROOT + '\\.ipollowork-plugin\\shell\\open\\command'); $k.SetValue('', 'original-open-command'); $k.Dispose()"]); + const before = query(); + action("Enable"); + assert.equal(query().icon, `"${join(state, "plugin-package.ico")}",0`); + action("Enable"); + assert.deepEqual(await readFile(join(state, "plugin-package.ico")), await readFile(icon)); + action("Restore"); + assert.deepEqual(query(), before); + action("Restore"); + assert.deepEqual(query(), before); + ps(["-Command", "[Microsoft.Win32.Registry]::CurrentUser.DeleteSubKey($env:ICON_TEST_ROOT + '\\.ipollowork-plugin\\DefaultIcon')"]); + action("Enable"); action("Restore"); + assert.deepEqual(query(), { icon: null, kind: null, command: "original-open-command" }); + } finally { + ps(["-Command", "[Microsoft.Win32.Registry]::CurrentUser.DeleteSubKeyTree($env:ICON_TEST_ROOT, $false)"]); + await rm(root, { recursive: true, force: true }); + } +}); From 3929fbd26d015162dd73ba325feb4fa32a143805 Mon Sep 17 00:00:00 2001 From: "jianyu.zhang" <3193941960@qq.com> Date: Thu, 15 Oct 2026 18:05:31 +0800 Subject: [PATCH 2/8] Daily Upgrade --- AGENTS.md | 2 +- apps/app/package.json | 2 +- apps/app/src/app/index.css | 1 + apps/app/src/i18n/locales/en.ts | 7 +- apps/app/src/i18n/locales/zh.ts | 7 +- .../domains/session/chat/session-page.tsx | 7 +- .../engine/opencode-conversation-engine.ts | 3 +- .../domains/session/panel/side-panel.tsx | 20 +- .../domains/session/sync/session-sync.ts | 16 +- .../pages/cloud-marketplaces-view.tsx | 29 +- .../settings/plugin-package-list-item.tsx | 15 +- .../settings/plugin-packages-panel.tsx | 100 ++-- .../domains/work/conversation-workspace.tsx | 44 +- .../plugin-ui/plugin-ui-contributions.ts | 14 +- .../app/tests/cloud-marketplaces-view.test.ts | 20 +- .../tests/conversation-engine-adapter.test.ts | 22 +- apps/app/tests/plugin-cloud-proof.tsx | 47 +- apps/app/tests/plugin-launcher-proof.html | 1 + apps/app/tests/plugin-launcher-proof.tsx | 57 ++ .../app/tests/plugin-ui-contributions.test.ts | 5 + .../tests/project-agent-presets-proof.html | 1 + .../app/tests/project-agent-presets-proof.tsx | 57 ++ .../app/tests/provider-engine-adapter.test.ts | 21 +- .../tests/session-sync-permissions.test.ts | 58 ++ .../app/tests/session-sync-tool-parts.test.ts | 57 ++ apps/desktop/package.json | 2 +- apps/desktop/scripts/electron-dev.mjs | 2 +- apps/server/src/codex-harness-runtime.test.ts | 21 +- apps/server/src/codex-harness-runtime.ts | 4 +- .../deepseek-harness-provider-sync.test.ts | 27 +- apps/server/src/deepseek-harness-runtime.ts | 6 +- apps/server/src/errors.ts | 2 +- .../src/ipollowork-runtime-config.test.ts | 17 +- apps/server/src/ipollowork-runtime-config.ts | 4 + apps/server/src/plugin-package-catalog.ts | 14 +- .../src/plugin-package-development.test.ts | 51 +- .../src/plugin-package-lifecycle.test.ts | 1 - apps/server/src/plugin-package-lifecycle.ts | 4 +- apps/server/src/provider-fetch.test.ts | 34 ++ apps/server/src/server.ts | 19 +- apps/server/src/work-items.test.ts | 65 +++ apps/server/src/work-items.ts | 31 +- docs/plugin-packages.md | 8 +- evals/flows/opencode-free-inference.flow.mjs | 8 +- .../opencode-zen-compact-picker.flow.mjs | 30 +- .../opencode-zen-models-unified.flow.mjs | 12 +- evals/flows/plugin-cloud-lifecycle.flow.mjs | 82 +-- evals/flows/plugin-launcher-icons.flow.mjs | 46 ++ evals/flows/project-agent-defaults.flow.mjs | 37 ++ evals/support/plugin-cloud-fixture.ts | 21 +- .../support/project-agent-presets-fixture.ts | 54 ++ evals/support/social-plugin-ui-fixture.mjs | 83 +++ evals/voiceovers/plugin-cloud-lifecycle.md | 18 +- .../wechat-official/ipollowork.plugin.json | 2 +- .../service/wechat-official.mjs | 12 +- .../service/wechat-official.test.mjs | 7 + .../wechat-official/ui/app.css | 288 ++++------ .../plugin-packages/wechat-official/ui/app.js | 196 ++++++- .../wechat-official/ui/index.html | 67 +-- .../xiaohongshu-ops/ipollowork.plugin.json | 2 +- .../xiaohongshu-ops/package.json | 2 +- .../skills/xhs-ops-worker/app/public/app.css | 510 +++++------------- .../skills/xhs-ops-worker/app/public/app.js | 155 +++++- .../skills/xhs-ops-worker/app/src/server.ts | 19 +- .../skills/xhs-ops-worker/app/src/views.ts | 46 +- .../xhs-ops-worker/app/tests/server.test.ts | 19 +- .../xhs-ops-worker/app/tests/studio.test.ts | 16 + .../types/src/opencode-zen-public-models.ts | 16 +- packages/types/src/provider-errors.ts | 12 + packages/types/src/work-items.ts | 13 +- scripts/package-plugin-collection.mjs | 2 +- 71 files changed, 1797 insertions(+), 903 deletions(-) create mode 100644 apps/app/tests/plugin-launcher-proof.html create mode 100644 apps/app/tests/plugin-launcher-proof.tsx create mode 100644 apps/app/tests/project-agent-presets-proof.html create mode 100644 apps/app/tests/project-agent-presets-proof.tsx create mode 100644 evals/flows/plugin-launcher-icons.flow.mjs create mode 100644 evals/flows/project-agent-defaults.flow.mjs create mode 100644 evals/support/project-agent-presets-fixture.ts create mode 100644 evals/support/social-plugin-ui-fixture.mjs diff --git a/AGENTS.md b/AGENTS.md index 3e0d7ad83..6b9e091dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ Feature work starts with the demo, not a PRD: ### Bundled social plugins -Maintain Xiaohongshu and Douyin in `examples/plugin-packages/xiaohongshu-ops` and `examples/plugin-packages/douyin-ops`. These directories are the authoritative source; the sibling repositories are recovery copies only. Do not push or publish plugin changes unless the user explicitly requests it. Keep the stable plugin IDs and user data paths so accounts, drafts, credentials and browser profiles survive updates. After each change, run the plugin package's own checks and tests, then verify installation or update through the host catalog and the affected fraimz flow. Development uses these local sources (closed packages from `dist/package`) and refreshes them on restart. Production desktop builds ship only internal `reference-context`; user plugins are independently signed cloud downloads. The catalog must not depend on `~/.ipollowork/local-plugin-packages`. +Maintain Xiaohongshu and Douyin in `examples/plugin-packages/xiaohongshu-ops` and `examples/plugin-packages/douyin-ops`. These directories are the authoritative source; the sibling repositories are recovery copies only. Do not push or publish plugin changes unless the user explicitly requests it. Keep the stable plugin IDs and user data paths so accounts, drafts, credentials and browser profiles survive updates. After each change, run the plugin package's own checks and tests, then verify installation or update through the host catalog and the affected fraimz flow. Development uses these local sources (closed packages from `dist/package`) and refreshes them on restart. Production desktop builds ship only internal `reference-context`. Personal manages the available local collection and installed/imported plugins; Public uses the online plugin API. Each view supports install, update from its own source and uninstall through the same installed-package lifecycle. The catalog must not depend on `~/.ipollowork/local-plugin-packages`. ### Mandatory AI Code Gate diff --git a/apps/app/package.json b/apps/app/package.json index 99d7672c7..733b642fb 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -1,7 +1,7 @@ { "name": "@ipollowork/app", "private": true, - "version": "0.50.19", + "version": "0.50.23", "type": "module", "scripts": { "test": "bun test --isolate tests/", diff --git a/apps/app/src/app/index.css b/apps/app/src/app/index.css index 06dcce709..362cfc969 100644 --- a/apps/app/src/app/index.css +++ b/apps/app/src/app/index.css @@ -64,6 +64,7 @@ img[src$="/ext-short-video.png"] { /* Keep monochrome plugin marks readable on both dark and light icon tiles. */ :is(.dark, [data-theme="dark"]) img:is( + [src$="/ext-image-studio.png"], [src$="/ext-xiaohongshu.png"], [src$="/ext-douyin.png"], [src$="/ext-wechat-channels.png"], diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index 14c9f3fed..aaa123644 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -345,12 +345,12 @@ export default { "plugin_library.marketplace_description": "Discover reviewed capability packages. Install once and use them in every project.", "plugin_library.personal": "Personal", "plugin_library.personal_title": "Personal plugins", - "plugin_library.personal_description": "Manage installed or imported plugins and update them from the cloud here. Compatible capabilities are shared across projects.", + "plugin_library.personal_description": "Browse the local plugin collection. Install, update, uninstall, or import packages from this computer.", "plugin_library.browse_public": "Browse public plugins", "plugin_platform.error.cloud_package_mismatch": "The downloaded package does not match the cloud version. Installation stopped and the existing plugin was preserved. Try again later.", - "plugin_library.check_updates": "Check for updates", + "plugin_library.check_updates": "Check local updates", "plugin_library.update_check_failed": "Cloud updates could not be checked. Installed plugins remain available.", - "plugin_library.personal_empty": "No plugins installed. Download one from Public to add it here.", + "plugin_library.personal_empty": "No local plugins available. Import a plugin package from this computer.", "plugin_library.featured": "Featured", "plugin_library.all_categories": "All categories", "plugin_library.all_statuses": "All statuses", @@ -1199,6 +1199,7 @@ export default { "project_overview.no_agent_presets": "No matching presets, or all have been added", "project_overview.agent_limit": "A conversation can have up to 24 agents", "project_overview.agents": "Agents", + "project_overview.new_conversations_inherit": "These are the project's default agents. New conversations inherit them; existing conversations keep their own configuration.", "project_overview.agent_name": "Name", "project_overview.agent_role": "Responsibility", "project_overview.agent_no_role": "No responsibility set", diff --git a/apps/app/src/i18n/locales/zh.ts b/apps/app/src/i18n/locales/zh.ts index 6d2931aaa..12d5ffc8a 100644 --- a/apps/app/src/i18n/locales/zh.ts +++ b/apps/app/src/i18n/locales/zh.ts @@ -348,12 +348,12 @@ export default { "plugin_library.marketplace_description": "发现经过审核的完整能力包;安装一次,即可在所有项目中使用。", "plugin_library.personal": "个人", "plugin_library.personal_title": "个人插件", - "plugin_library.personal_description": "仅显示已安装或导入的插件。可检查云端版本并在此更新;所有项目共享兼容能力。", + "plugin_library.personal_description": "浏览本地插件集,在此安装、更新或卸载;也可导入本机插件包。", "plugin_library.browse_public": "浏览公开插件", "plugin_platform.error.cloud_package_mismatch": "下载包与云端版本信息不一致,已停止安装,原有插件保持不变。请稍后重试。", - "plugin_library.check_updates": "检查更新", + "plugin_library.check_updates": "检查本地更新", "plugin_library.update_check_failed": "暂时无法检查云端更新,已安装插件仍可使用。", - "plugin_library.personal_empty": "还没有安装插件。到公开市场下载后,插件会显示在这里。", + "plugin_library.personal_empty": "当前没有本地插件,可导入本机插件包后使用。", "plugin_library.featured": "精选", "plugin_library.all_categories": "全部分类", "plugin_library.all_statuses": "全部状态", @@ -1201,6 +1201,7 @@ export default { "project_overview.no_agent_presets": "没有匹配的预设,或已全部添加", "project_overview.agent_limit": "一个对话最多配置 24 个智能体", "project_overview.agents": "智能体", + "project_overview.new_conversations_inherit": "这里保存项目默认智能体。之后新建的对话会继承,已有对话的配置保持不变。", "project_overview.agent_name": "名称", "project_overview.agent_role": "职责", "project_overview.agent_no_role": "还没有设置职责", diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx index 6fb7c93ea..786cfd0d0 100644 --- a/apps/app/src/react-app/domains/session/chat/session-page.tsx +++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx @@ -84,6 +84,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { toast } from "@/components/ui/sonner"; import { Textarea } from "@/components/ui/textarea"; import { ConfirmModal } from "../../../design-system/modals/confirm-modal"; +import { resolveExtensionIconUrl } from "@/react-app/design-system/extension-icon-src"; import { useDenAuth } from "../../cloud/den-auth-provider"; import ProviderAuthModal, { type ProviderAuthModalProps } from "../../connections/provider-auth/provider-auth-modal"; import { RenameSessionModal } from "../modals/rename-session-modal"; @@ -1706,7 +1707,7 @@ export function SessionPage(props: SessionPageProps) { )); const sessionPanelState = useSessionPanelState(props.selectedSessionId ?? ""); const activePanelTab = useActivePanelTab(props.selectedSessionId ?? ""); - const { workspaceApps } = useInstalledPluginContributions( + const { workspaceApps, isLoading: pluginsLoading } = useInstalledPluginContributions( props.ipolloworkServerClient, props.runtimeWorkspaceId, ); @@ -4074,6 +4075,7 @@ export function SessionPage(props: SessionPageProps) { label: isMediaStudioPlugin(surface.pluginId) ? t("media.studio.title") : surface.label, group: "studio", icon: mediaStudioEngine(surface) === "image-studio" ? "image-studio" : mediaStudioEngine(surface) === "video-console" ? "video-console" : "workspace-app", + iconSrc: resolveExtensionIconUrl({ pluginId: surface.pluginId, iconSrc: surface.iconSrc ?? undefined, iconSlug: surface.iconSlug }), onClick: () => openWorkspaceApp(surface), disabled: !props.selectedWorkspaceId || sessionPanelState.tabs.some((tab) => tab.id === workspaceAppTabId(surface)), })), @@ -5273,7 +5275,7 @@ export function SessionPage(props: SessionPageProps) { {sidePanelOpen && effectiveSidePanelView === "launcher" ? (
- + {launcherBusy ? : null}
@@ -5313,6 +5315,7 @@ export function SessionPage(props: SessionPageProps) { workspaceRoot={props.selectedWorkspaceRoot} isRemoteWorkspace={props.surface?.isRemoteWorkspace ?? false} launcherItems={sidePanelLauncherItems} + pluginsLoading={pluginsLoading} aiEditing={selectedSessionStatus ? selectedSessionStatus.type === "busy" || selectedSessionStatus.type === "retry" : isStreamingSessionStatus(props.sidebar.sessionStatusById[props.selectedSessionId])} onAskAi={handleDesignAskAi} onSendWorkspaceAppMessage={sendWorkspaceAppMessage} diff --git a/apps/app/src/react-app/domains/session/engine/opencode-conversation-engine.ts b/apps/app/src/react-app/domains/session/engine/opencode-conversation-engine.ts index cbc921e58..56325a8dd 100644 --- a/apps/app/src/react-app/domains/session/engine/opencode-conversation-engine.ts +++ b/apps/app/src/react-app/domains/session/engine/opencode-conversation-engine.ts @@ -350,7 +350,8 @@ function openCodeConnection(input: { baseUrl: string; token?: string; directory? if (input.signal?.aborted) return { sessionId: input.sessionId }; const result = await client.session.promptAsync({ sessionID: input.sessionId, - messageID: input.clientUserMessageId, + // Keep optimistic IDs local. OpenCode mints the authoritative ID + // used by its provider request; session sync reconciles the echo. parts: promptParts, model: input.model, agent, diff --git a/apps/app/src/react-app/domains/session/panel/side-panel.tsx b/apps/app/src/react-app/domains/session/panel/side-panel.tsx index 87655f470..11e424a1c 100644 --- a/apps/app/src/react-app/domains/session/panel/side-panel.tsx +++ b/apps/app/src/react-app/domains/session/panel/side-panel.tsx @@ -83,6 +83,7 @@ type SidePanelProps = { workspaceRoot: string; isRemoteWorkspace?: boolean; launcherItems?: SidePanelLauncherItem[]; + pluginsLoading?: boolean; onClose: () => void; onAskAi?: (context: DesignAiSelectionContext, instruction?: string) => void | Promise; onSendWorkspaceAppMessage?: (input: { text: string; modelContext: WorkspaceAppModelContext | null; sourceTabId?: string }) => WorkspaceAppMessageResult | Promise; @@ -104,12 +105,14 @@ export type SidePanelLauncherItem = { group: "content" | "studio"; shortcut?: string; icon: "web" | "design" | "files" | "video" | "plugin-workshop" | "image-studio" | "video-console" | "workspace-app"; + iconSrc?: string; disabled?: boolean; onClick: () => void; }; -export function SidePanelLauncherMenu({ launcherItems, expanded = false, isBrowserAvailable = false, onCreateBrowser }: { +export function SidePanelLauncherMenu({ launcherItems, pluginsLoading = false, expanded = false, isBrowserAvailable = false, onCreateBrowser }: { launcherItems: SidePanelLauncherItem[]; + pluginsLoading?: boolean; expanded?: boolean; isBrowserAvailable?: boolean; onCreateBrowser?: () => void; @@ -139,6 +142,7 @@ export function SidePanelLauncherMenu({ launcherItems, expanded = false, isBrows align="end" positionerClassName={expanded ? "z-[70]" : undefined} className="w-56" + aria-busy={pluginsLoading} > {launcherItems.map((item, index) => { return ( @@ -157,6 +161,12 @@ export function SidePanelLauncherMenu({ launcherItems, expanded = false, isBrows ); })} + {pluginsLoading ? ( +
+ + {t("common.loading")} +
+ ) : null} {launcherItems.length === 0 && isBrowserAvailable ? ( (); const icon = item.icon === "web" ? : item.icon === "design" @@ -191,7 +202,9 @@ export function SidePanelLauncherIcon({ item }: { item: SidePanelLauncherItem }) aria-hidden="true" className="grid size-5 shrink-0 place-items-center text-muted-foreground [&_svg]:shrink-0" > - {React.cloneElement(icon, { strokeWidth: NAVIGATION_ICON_STROKE_WIDTH })} + {item.iconSrc && item.iconSrc !== failedIconSrc + ? setFailedIconSrc(item.iconSrc)} /> + : React.cloneElement(icon, { strokeWidth: NAVIGATION_ICON_STROKE_WIDTH })} ); } @@ -760,6 +773,7 @@ export function SidePanel({ workspaceRoot, isRemoteWorkspace = false, launcherItems = [], + pluginsLoading = false, onAskAi, onSendWorkspaceAppMessage, onEditImage, @@ -942,7 +956,7 @@ export function SidePanel({ ))} {isBrowserAvailable || launcherItems.length > 0 ? ( - createTab()} /> + createTab()} /> ) : null} diff --git a/apps/app/src/react-app/domains/session/sync/session-sync.ts b/apps/app/src/react-app/domains/session/sync/session-sync.ts index 4e43008ec..bae0edb0e 100644 --- a/apps/app/src/react-app/domains/session/sync/session-sync.ts +++ b/apps/app/src/react-app/domains/session/sync/session-sync.ts @@ -1621,10 +1621,8 @@ export function seedSessionState(workspaceId: string, snapshot: ConversationSnap : existingRaw; const preserveOptimisticBusy = safeSnapshot.status.type === "idle" && existingRaw?.some(isOptimisticUserMessage) === true; - const preserveLiveBusy = safeSnapshot.status.type === "idle" - && liveActivityBeforeSeed - && !snapshotAcknowledgesRun; - const preserveBusy = preserveOptimisticBusy || preserveLiveBusy; + // An older snapshot may fill history, but cannot settle or fail a newer turn. + const preserveRunState = preserveOptimisticBusy || !snapshotAcknowledgesRun; const reconciledMessages = applyRevertCursor( reconcileTranscriptMessages({ currentMessages: existing ?? [], @@ -1638,14 +1636,14 @@ export function seedSessionState(workspaceId: string, snapshot: ConversationSnap const assistantOutput = assistantOutputAfterLatestUser(reconciledMessages); const finalAssistantOutput = finalAssistantOutputAfterLatestUser(reconciledMessages); const terminalWithoutOutput = safeSnapshot.status.type === "idle" - && !preserveBusy + && !preserveRunState && liveActivityBeforeSeed && snapshotAcknowledgesRun && latestUserMessage(incoming) !== null && !snapshotErrorText && !finalAssistantOutput; - if (!preserveBusy) { + if (!preserveRunState) { useSessionActivityStore.getState().seedSessionRun( workspaceId, safeSnapshot.session.id, @@ -1661,7 +1659,7 @@ export function seedSessionState(workspaceId: string, snapshot: ConversationSnap queryClient.setQueryData(key, recovered.messages); settleRecoveredNoFinalOutputError(workspaceId, safeSnapshot.session.id, recovered.turnId); - if (snapshotErrorText) { + if (snapshotErrorText && snapshotAcknowledgesRun) { useSessionActivityStore.getState().setError(workspaceId, safeSnapshot.session.id, snapshotErrorText, latestUserMessage(incoming)?.id); } else if (terminalWithoutOutput) { const latestUser = latestUserMessage(incoming); @@ -1673,7 +1671,7 @@ export function seedSessionState(workspaceId: string, snapshot: ConversationSnap ...(latestUser ? { parentUserMessageId: latestUser.id } : {}), }), )); - } else if (safeSnapshot.status.type === "idle" && !preserveBusy && snapshotAcknowledgesRun) { + } else if (safeSnapshot.status.type === "idle" && !preserveRunState && snapshotAcknowledgesRun) { const latestAssistant = incoming.findLast((message) => message.role === "assistant"); const metadata = latestAssistant?.metadata; const ipollowork = metadata && typeof metadata === "object" && "ipollowork" in metadata @@ -1688,7 +1686,7 @@ export function seedSessionState(workspaceId: string, snapshot: ConversationSnap } } - if (!preserveBusy) { + if (!preserveRunState) { queryClient.setQueryData(statusKey(workspaceId, safeSnapshot.session.id), safeSnapshot.status); } queryClient.setQueryData(todoKey(workspaceId, safeSnapshot.session.id), safeSnapshot.todos); diff --git a/apps/app/src/react-app/domains/settings/pages/cloud-marketplaces-view.tsx b/apps/app/src/react-app/domains/settings/pages/cloud-marketplaces-view.tsx index 1fdde7e42..aae818a90 100644 --- a/apps/app/src/react-app/domains/settings/pages/cloud-marketplaces-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/cloud-marketplaces-view.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource react */ import * as React from "react"; -import { Cloud, Loader2, RefreshCw, ShieldCheck } from "lucide-react"; +import { Cloud, Loader2, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"; import type { iPolloWorkExtensionManifest } from "@/app/extensions"; import { @@ -208,6 +208,19 @@ export function CloudMarketplacesView({ } }, [client, installed, onInstalled, refresh, workspaceId]); + const uninstall = async (resource: EnterpriseResource) => { + if (!client || !workspaceId || busyId !== null) return; + setBusyId(resource.id); + setError(null); + try { + await client.uninstallPluginPackage(workspaceId, resourcePluginId(resource)); + await refresh(); + notifyPluginUiContributionsChanged(); + } catch (cause) { + setError(formatPluginPlatformError(cause, t("plugin_platform.error.operation"))); + } finally { setBusyId(null); } + }; + const filteredItems = React.useMemo(() => { const query = search.trim().toLocaleLowerCase(); return items.filter((item) => { @@ -259,10 +272,12 @@ export function CloudMarketplacesView({ description={selected.description} onBack={() => setSelectedId(null)} action={( - + {localPackage ? : null} )} >
@@ -313,6 +328,7 @@ export function CloudMarketplacesView({ onOpen={setSelectedId} onOpenInstalled={onOpenInstalled} onInstall={install} + onUninstall={uninstall} /> ) : null} @@ -327,6 +343,7 @@ export function CloudMarketplacesView({ onOpen={setSelectedId} onOpenInstalled={onOpenInstalled} onInstall={install} + onUninstall={uninstall} /> ) : null} @@ -342,6 +359,7 @@ export function CloudMarketplacesView({ onOpen={setSelectedId} onOpenInstalled={onOpenInstalled} onInstall={install} + onUninstall={uninstall} /> )) : null} @@ -360,6 +378,7 @@ type MarketplaceSectionProps = { onOpen: (resourceId: string) => void; onOpenInstalled?: (pluginId: string) => void; onInstall: (item: EnterpriseResource) => Promise; + onUninstall: (item: EnterpriseResource) => Promise; }; function MarketplaceSection(props: MarketplaceSectionProps) { @@ -391,10 +410,10 @@ function MarketplaceRows(props: Omit) { status={localPackage ? (cloudPluginUpdate([item], localPackage) ? t("extensions.update_available") : t("settings.marketplace.installed")) : item.enterpriseCategory} actionBusy={props.busyId === item.id} actionDisabled={!props.client || !props.workspaceId || !version || props.busyId !== null || Boolean(localPackage && !cloudPluginUpdate([item], localPackage))} + secondaryAction={localPackage ? : null} actionLabel={actionLabel(item, localPackage)} - onOpen={() => localPackage && props.onOpenInstalled - ? props.onOpenInstalled(pluginId) - : props.onOpen(item.id)} + onOpen={() => props.onOpen(item.id)} onAction={() => void props.onInstall(item)} /> ); diff --git a/apps/app/src/react-app/domains/settings/plugin-package-list-item.tsx b/apps/app/src/react-app/domains/settings/plugin-package-list-item.tsx index 44682e55a..68e0315f9 100644 --- a/apps/app/src/react-app/domains/settings/plugin-package-list-item.tsx +++ b/apps/app/src/react-app/domains/settings/plugin-package-list-item.tsx @@ -15,6 +15,7 @@ type PluginPackageListItemProps = { actionLabel: ReactNode; actionBusy?: boolean; actionDisabled?: boolean; + secondaryAction?: ReactNode; featured?: boolean; compact?: boolean; onOpen?: () => void; @@ -29,6 +30,7 @@ export function PluginPackageListItem({ actionLabel, actionBusy = false, actionDisabled = false, + secondaryAction, featured = false, compact = false, onOpen, @@ -44,7 +46,7 @@ export function PluginPackageListItem({ if (compact) { return ( -
+
+ {secondaryAction}
); } @@ -94,6 +100,7 @@ export function PluginPackageListItem({ {actionBusy ?
); } diff --git a/apps/app/src/react-app/domains/settings/plugin-packages-panel.tsx b/apps/app/src/react-app/domains/settings/plugin-packages-panel.tsx index a3ec2c1f5..9018935e6 100644 --- a/apps/app/src/react-app/domains/settings/plugin-packages-panel.tsx +++ b/apps/app/src/react-app/domains/settings/plugin-packages-panel.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource react */ -import { cloudPluginUpdate, installCloudPluginResource, listEnterpriseResources, type EnterpriseResource } from "@/app/lib/enterprise-connections"; +import { listEnterpriseResources, type EnterpriseResource } from "@/app/lib/enterprise-connections"; import { useCloudSession } from "./cloud/cloud-session-provider"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type ReactNode } from "react"; import { @@ -16,10 +16,12 @@ import { Plug, ShieldCheck, Sparkles, + Trash2, WandSparkles, } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { toast } from "@/components/ui/sonner"; import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { currentLocale, t, translationKey } from "@/i18n"; @@ -180,6 +182,8 @@ function pluginPackageBadges( export const PluginPackagesPanel = forwardRef(function PluginPackagesPanel(props, ref) { const locale = currentLocale(); const [items, setItems] = useState([]); + const [catalogItems, setCatalogItems] = useState([]); + const [catalogError, setCatalogError] = useState(null); const cloud = useCloudSession(); const [cloudResources, setCloudResources] = useState([]); const [cloudError, setCloudError] = useState(null); @@ -207,6 +211,8 @@ export const PluginPackagesPanel = forwardRef client.listPluginPackages(workspaceId)), - cloud.isSignedIn ? listEnterpriseResources("extension") : Promise.resolve([]), + loadPluginPackageData(() => client.listBundledPluginPackages(workspaceId)), + source === "marketplace" && cloud.isSignedIn ? listEnterpriseResources("extension") : Promise.resolve([]), ]); if (generation !== refreshGeneration.current) return; @@ -241,6 +248,12 @@ export const PluginPackagesPanel = forwardRef ({ refresh: () => void refresh(), @@ -271,7 +284,7 @@ export const PluginPackagesPanel = forwardRef { refreshGeneration.current++; }; }, [refresh]); useEffect(() => { - if (!cloud.isSignedIn || source !== "personal") return; + if (!cloud.isSignedIn || source !== "marketplace") return; const check = () => { if (Date.now() - lastCloudCheck.current >= 60_000) void refresh(); }; window.addEventListener("focus", check); const timer = window.setInterval(check, 300_000); @@ -279,8 +292,8 @@ export const PluginPackagesPanel = forwardRef collectPluginPackageRelationships(items, []), - [items], + () => collectPluginPackageRelationships(items, catalogItems), + [items, catalogItems], ); const localizedItems = useMemo(() => items.map((sourceItem) => { const manifest = localizePluginPackageManifest( @@ -302,6 +315,15 @@ export const PluginPackagesPanel = forwardRef [item.name, item.manifest.description, item.manifest.category ?? ""] .some((value) => value.toLocaleLowerCase().includes(query))); }, [localizedItems, search]); + const filteredCatalogItems = useMemo(() => { + const query = search.trim().toLocaleLowerCase(); + return catalogItems.filter(entry => !items.some(item => item.pluginId === entry.pluginId)).flatMap(item => { + const manifest = localizePluginPackageManifest(item.manifest, locale); + return !query || [manifest.name, manifest.description, manifest.category ?? ""].some(value => value.toLocaleLowerCase().includes(query)) + ? [{ ...item, manifest }] : []; + }); + }, [catalogItems, items, locale, search]); + useEffect(() => { props.onRelationshipsChange(relationships); }, [props.onRelationshipsChange, relationships]); @@ -332,11 +354,13 @@ export const PluginPackagesPanel = forwardRef run(`cloud:${resource.id}`, async () => { + const installBundledPackage = (resource: iPolloWorkBundledPluginPackageItem) => run(`catalog:${resource.pluginId}`, async () => { if (!props.client || !props.workspaceId) return; - await installCloudPluginResource(resource, props.client, props.workspaceId); + await props.client.installBundledPluginPackage(props.workspaceId, resource.pluginId); + await refresh(); + notifyPluginUiContributionsChanged(); + }); + + const uninstallPackage = (pluginId: string) => run(`${pluginId}:remove`, async () => { + if (!props.client || !props.workspaceId) return; + await props.client.uninstallPluginPackage(props.workspaceId, pluginId); + props.onSelectPlugin(null); await refresh(); notifyPluginUiContributionsChanged(); }); @@ -397,7 +429,7 @@ export const PluginPackagesPanel = forwardRef entry.pluginId === selectedSourceItem.pluginId && entry.updateAvailable); const localizedManifest = localizePluginPackageManifest( selectedSourceItem.manifest, locale, @@ -470,15 +502,15 @@ export const PluginPackagesPanel = forwardRefv{item.version}
- {cloudUpdate ? ( + {catalogUpdate ? ( ) : null} + {error ?

{error}

: null} +
+
+ {loading ?

正在加载通讯录

: null} + {search.trim() ? searchPage?.items.map(memberRow) : <> + {departments.filter((item) => !item.parentId || !departments.some((parent) => parent.id === item.parentId)).map((item) => departmentNode(item, new Set()))} + + {expanded.has("unassigned") ? pages.unassigned?.items.map(memberRow) : null} + {expanded.has("unassigned") && pages.unassigned?.nextCursor ? : null} + {departmentCursor ? : null} + } + {search.trim() && searchPage?.nextCursor ? : null} + {searchPage && !searchPage.items.length && !loading ?

没有匹配的人员

: null} +
+

已选 {value.memberIds.length} 人{allowDepartments ? ` · ${value.departmentIds.length} 个部门` : ""}

+ {value.memberIds.map((id, index) =>
{labelForMember(id, index)}
)} + {value.departmentIds.map((id) =>
{departments.find((item) => item.id === id)?.name ?? "已选部门"}
)} +
+
+ ; +} diff --git a/apps/app/src/react-app/domains/enterprise/enterprise-resource-controls.tsx b/apps/app/src/react-app/domains/enterprise/enterprise-resource-controls.tsx new file mode 100644 index 000000000..0cea27071 --- /dev/null +++ b/apps/app/src/react-app/domains/enterprise/enterprise-resource-controls.tsx @@ -0,0 +1,126 @@ +import { useEffect, useRef, useState } from "react"; +import { Loader2, RefreshCw, ShieldCheck, Upload } from "lucide-react"; +import type { EnterpriseAccess, EnterpriseManagedResource, EnterpriseSubmission } from "@ipollowork/types/enterprise"; +import { MAX_TEMPLATE_PACKAGE_BYTES } from "@ipollowork/types/templates"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { canManageEnterprise, enterpriseApi, enterpriseErrorMessage, enterpriseIdentity } from "@/app/lib/enterprise-client"; +import type { EnterpriseConnection } from "@/app/lib/enterprise-connections"; +import { EnterprisePeoplePicker } from "./enterprise-people-picker"; + +const REVIEW_LABELS = { pending: "待审核", approved: "已通过", rejected: "已退回" }; +const STATUS_LABELS = { draft: "草稿", published: "已发布", archived: "已下架" }; + +function ResourceAccessDialog({ connection, resource, onClose, onSaved }: { + connection: EnterpriseConnection; resource: EnterpriseManagedResource; onClose: () => void; onSaved: () => void; +}) { + const [access, setAccess] = useState(null); + const [includeDescendants, setIncludeDescendants] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + useEffect(() => { + let current = true; + void enterpriseApi.access(connection, resource.id).then((value) => { if (current) { setAccess(value); setIncludeDescendants(value.departments.length ? value.departments.every((item) => item.includeDescendants) : true); } }) + .catch((cause: unknown) => { if (current) setError(enterpriseErrorMessage(cause)); }); + return () => { current = false; }; + }, [connection, resource.id]); + const save = async () => { + if (!access) return; + setBusy(true); setError(null); + try { await enterpriseApi.setAccess(connection, resource.id, access); onSaved(); onClose(); } + catch (cause) { setError(enterpriseErrorMessage(cause)); } + finally { setBusy(false); } + }; + return { if (!open && !busy) onClose(); }}>分配资源权限{resource.name} · {connection.name} + {error ?

{error}

: null} + {access ? <> +
{(["restricted", "all", "admins"] as const).map((scope) => )}
+ {access.access === "restricted" ? <> item.id) }} onChange={(selected) => setAccess({ ...access, memberIds: selected.memberIds, departments: selected.departmentIds.map((id) => access.departments.find((item) => item.id === id) ?? { id, includeDescendants }) })} /> + + {access.groupIds.length ?

同时保留 {access.groupIds.length} 个已有访问组授权。

: null} + : null} +

保存后通知员工。清空指定对象可收回该范围权限,已下载的本地副本另行管理。

+ : !error ?

正在读取权限

: null} + +
; +} + +export function EnterpriseResourceControls({ connection, type, onChanged }: { + connection: EnterpriseConnection; type: "template" | "extension"; onChanged?: () => void; +}) { + const [open, setOpen] = useState(false); + const [view, setView] = useState<"manage" | "mine">("mine"); + const [resources, setResources] = useState([]); + const [submissions, setSubmissions] = useState([]); + const [cursor, setCursor] = useState(null); + const [search, setSearch] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [accessResource, setAccessResource] = useState(null); + const [reviewResource, setReviewResource] = useState(null); + const [reviewNote, setReviewNote] = useState(""); + const [file, setFile] = useState(null); + const input = useRef(null); + const epoch = useRef(0); + const manager = canManageEnterprise(connection); + const publishing = type === "template" ? connection.features?.templatePublishing : connection.features?.extensionPublishing; + const label = type === "template" ? "模板" : "插件"; + const identity = enterpriseIdentity(connection); + useEffect(() => { setOpen(false); setResources([]); setSubmissions([]); setAccessResource(null); setReviewResource(null); setFile(null); setError(null); setNotice(null); setBusy(false); epoch.current += 1; }, [identity]); + useEffect(() => { setError(null); setNotice(null); if (!manager) { setView("mine"); setResources([]); setAccessResource(null); setReviewResource(null); setBusy(false); epoch.current += 1; } }, [manager]); + const refresh = async (more = false) => { + const request = ++epoch.current; + setBusy(true); setError(null); + try { + if (view === "manage") { + if (!manager || !connection.features?.resourceManagement) throw new Error("enterprise_feature_unavailable"); + const page = await enterpriseApi.resources(connection, type, more ? cursor : null, search); + if (request === epoch.current) { setResources((current) => more ? [...new Map([...current, ...page.items].map((item) => [item.id, item])).values()].slice(0, 500) : page.items); setCursor(page.nextCursor === cursor && more ? null : page.nextCursor); } + } else { + if (!publishing) throw new Error("enterprise_feature_unavailable"); + const page = await enterpriseApi.submissions(connection, type); + if (request === epoch.current) setSubmissions(page.items); + } + } catch (cause) { if (request === epoch.current) setError(enterpriseErrorMessage(cause)); } + finally { if (request === epoch.current) setBusy(false); } + }; + useEffect(() => { const timer = open ? window.setTimeout(() => void refresh(), search ? 250 : 0) : undefined; return () => { window.clearTimeout(timer); epoch.current += 1; }; }, [open, view, identity, type, search]); + const mutation = async (action: () => Promise, success: string) => { + const request = epoch.current; + setBusy(true); setError(null); setNotice(null); + try { await action(); if (request !== epoch.current) return; setNotice(success); setFile(null); onChanged?.(); await refresh(); } + catch (cause) { if (request === epoch.current) setError(enterpriseErrorMessage(cause)); } + finally { if (request === epoch.current) setBusy(false); } + }; + const upload = () => { + if (!file || !publishing) return; + const valid = type === "template" ? /\.(ipwp|ipwt)$/i.test(file.name) : /\.ipollowork-plugin$/i.test(file.name); + if (!valid || !file.size || file.size > MAX_TEMPLATE_PACKAGE_BYTES) { setError(`请选择有效的${label}包,文件大小不能超过 ${Math.round(MAX_TEMPLATE_PACKAGE_BYTES / 1024 / 1024)} MB。`); return; } + void mutation(() => enterpriseApi.submit(connection, type, file), "投稿已提交,可在我的投稿中查看审核状态。"); + }; + return <> + + { if (!busy) setOpen(value); }}>{manager ? `企业${label}管理` : `我的${label}投稿`}{connection.name} · {manager ? "查看资源和投稿,分配使用权限" : "提交自己制作的资源,查看审核结果"} +
{manager ? : null}
+
setFile(event.target.files?.[0] ?? null)} />{file?.name ?? `上传自己制作的${label},审核后在企业内分发`}
+ {!publishing ?

当前企业服务尚未开放员工{label}投稿,请联系管理员升级。

: null} + {error ?

{error}

: null} + {notice ?

{notice}

: null} + {busy ?

正在处理

: null} + {view === "manage" && manager ? <>
+ {resources.map((item) =>

{item.name}

{STATUS_LABELS[item.status]} · {item.latestVersion ? `v${item.latestVersion.version} · ${REVIEW_LABELS[item.latestVersion.reviewStatus]}` : "暂无版本"}{item.ownerName ? ` · ${item.ownerName}` : ""}

+ + {item.latestVersion?.reviewStatus === "pending" ? : null} + {item.status === "published" ? : null} +
)} + {!resources.length && !busy && !error ?

企业中暂无{label}

: null} +
{cursor && resources.length < 500 ? : null}{resources.length >= 500 ?

已显示前 500 个资源,请在企业管理服务中查看完整列表。

: null} :
{submissions.map((item) =>

{item.resourceName || label} · v{item.version}

{REVIEW_LABELS[item.reviewStatus]}{item.reviewNote ? ` · ${item.reviewNote}` : ""}

)}{!submissions.length && !busy && !error ?

还没有投稿,选择自己的{label}包提交审核。

: null}
} +
+ {manager && accessResource ? setAccessResource(null)} onSaved={() => { setNotice("权限已保存并下发。"); void refresh(); }} /> : null} + { if (!value && !busy) setReviewResource(null); }}>审核{label}投稿{reviewResource?.name}