diff --git a/docs/specs/acp-agent-uninstall/plan.md b/docs/specs/acp-agent-uninstall/plan.md new file mode 100644 index 000000000..117ec3693 --- /dev/null +++ b/docs/specs/acp-agent-uninstall/plan.md @@ -0,0 +1,21 @@ +# ACP Agent Uninstall Plan + +## Main Process + +- Add `configPresenter.uninstallAcpRegistryAgent(agentId)` as the orchestration entrypoint. +- Keep sqlite persistence in `AgentRepository`. +- Add uninstall cleanup to `AcpLaunchSpecService` for registry install artifacts with path-boundary checks. +- After uninstall, mark the agent disabled and set install state back to `not_installed`. +- Reuse `handleAcpAgentsMutated([agentId])` so ACP processes are released and renderer state refreshes. + +## Renderer + +- Add uninstall actions in `AcpSettings.vue` for both installed cards and registry overlay rows. +- Confirm uninstall with a lightweight AlertDialog-based confirmation flow (`AlertDialog` component/modal) so the renderer uses the built-in AlertDialog UI for lightweight confirmation. +- Refresh ACP settings data after uninstall completes. + +## Tests + +- Cover binary uninstall cleanup and safe path handling in `AcpLaunchSpecService`. +- Cover repository/state reset for registry agents. +- Cover renderer uninstall CTA wiring in `AcpSettings.vue`. diff --git a/docs/specs/acp-agent-uninstall/spec.md b/docs/specs/acp-agent-uninstall/spec.md new file mode 100644 index 000000000..2b7922bea --- /dev/null +++ b/docs/specs/acp-agent-uninstall/spec.md @@ -0,0 +1,25 @@ +# ACP Agent Uninstall + +## Summary + +Add uninstall support for registry-backed ACP agents. The current ACP settings flow can install and enable registry agents, but it cannot uninstall them after they are added. + +## User Stories + +- As a user, I can uninstall an installed ACP registry agent from settings. +- As a user, uninstall removes local install artifacts and hides the agent from enabled ACP model choices. +- As a user, old sessions referencing that agent are preserved and can recover by reinstalling later. + +## Acceptance Criteria + +- ACP settings shows an uninstall action for installed registry agents. +- Uninstall deletes local binary install directories when the agent uses a binary distribution. +- Uninstall resets registry install state to `not_installed` and disables the agent. +- Uninstalled registry agents no longer appear in the enabled ACP model list. +- Existing session records keep their `agentId`; uninstall does not delete session history. + +## Non-Goals + +- No hard-delete of the registry agent row. +- No schema migration. +- No forced rewrite of historical session, remote binding, or subagent references. diff --git a/src/main/presenter/agentRepository/index.ts b/src/main/presenter/agentRepository/index.ts index effaf9592..e9de0ad2a 100644 --- a/src/main/presenter/agentRepository/index.ts +++ b/src/main/presenter/agentRepository/index.ts @@ -384,6 +384,24 @@ export class AgentRepository { return true } + clearRegistryAcpAgentInstallation(agentId: string, installState: AcpAgentInstallState): boolean { + const row = this.sqlitePresenter.agentsTable.get(agentId) + if (!row || row.agent_type !== 'acp' || row.source !== 'registry') { + return false + } + + const state = parseJson(row.state_json) ?? {} + this.sqlitePresenter.agentsTable.update(agentId, { + enabled: false, + stateJson: stringifyJson({ + ...state, + installState + } satisfies StoredAgentState) + }) + + return true + } + toAcpAgentConfig( agentId: string, preview?: Pick diff --git a/src/main/presenter/configPresenter/acpLaunchSpecService.ts b/src/main/presenter/configPresenter/acpLaunchSpecService.ts index 4af55781a..bd2980983 100644 --- a/src/main/presenter/configPresenter/acpLaunchSpecService.ts +++ b/src/main/presenter/configPresenter/acpLaunchSpecService.ts @@ -52,6 +52,15 @@ const ensureDir = (dir: string): void => { } } +const isPathWithinRoot = (candidatePath: string, rootPath: string, allowEqual = false): boolean => { + const relative = path.relative(rootPath, candidatePath) + if (!relative) { + return allowEqual + } + + return !relative.startsWith('..') && !path.isAbsolute(relative) +} + const listFilesRecursive = (root: string): string[] => { if (!fs.existsSync(root)) { return [] @@ -218,6 +227,28 @@ export class AcpLaunchSpecService { } } + async uninstallRegistryAgent( + agent: AcpRegistryAgent, + installState: AcpAgentInstallState | null + ): Promise { + const selection = this.selectRegistryDistribution(agent) + if (selection?.type !== 'binary') { + return + } + + const candidateDirs = new Set() + if (typeof installState?.installDir === 'string' && installState.installDir.trim()) { + candidateDirs.add(path.resolve(installState.installDir)) + } + candidateDirs.add(this.getBinaryInstallDir(agent)) + + for (const installDir of candidateDirs) { + this.removeInstallDir(installDir) + } + + this.pruneEmptyAgentInstallRoot(agent.id) + } + async resolveRegistryLaunchSpec( agent: AcpRegistryAgent, installState: AcpAgentInstallState | null @@ -289,10 +320,7 @@ export class AcpLaunchSpecService { const resolvedInstallDir = path.resolve(installDir) const resolvedInstallRoot = path.resolve(this.installRoot) - if ( - resolvedInstallDir !== resolvedInstallRoot && - !resolvedInstallDir.startsWith(`${resolvedInstallRoot}${path.sep}`) - ) { + if (!isPathWithinRoot(resolvedInstallDir, resolvedInstallRoot)) { throw new Error(`Unsafe ACP install directory for ${agent.id}@${agent.version}`) } @@ -310,6 +338,36 @@ export class AcpLaunchSpecService { return listFilesRecursive(installDir).find((file) => path.basename(file) === basename) ?? null } + private removeInstallDir(installDir: string): void { + const resolvedInstallDir = path.resolve(installDir) + const resolvedInstallRoot = path.resolve(this.installRoot) + + if (!isPathWithinRoot(resolvedInstallDir, resolvedInstallRoot)) { + throw new Error(`Unsafe ACP install directory for uninstall: ${installDir}`) + } + + if (!fs.existsSync(resolvedInstallDir)) { + return + } + + fs.rmSync(resolvedInstallDir, { recursive: true, force: true }) + } + + private pruneEmptyAgentInstallRoot(agentId: string): void { + const safeAgentId = sanitizeInstallSegment(agentId, 'agent id') + const agentRoot = path.resolve(path.join(this.installRoot, safeAgentId)) + const resolvedInstallRoot = path.resolve(this.installRoot) + + if (!isPathWithinRoot(agentRoot, resolvedInstallRoot) || !fs.existsSync(agentRoot)) { + return + } + + const remainingEntries = fs.readdirSync(agentRoot) + if (remainingEntries.length === 0) { + fs.rmSync(agentRoot, { recursive: true, force: true }) + } + } + private getPlatformKey(): string | null { const platformMap: Record = { darwin: 'darwin', diff --git a/src/main/presenter/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts index 22767fa29..5aafd5df5 100644 --- a/src/main/presenter/configPresenter/index.ts +++ b/src/main/presenter/configPresenter/index.ts @@ -1635,6 +1635,35 @@ export class ConfigPresenter implements IConfigPresenter { } } + async uninstallAcpRegistryAgent(agentId: string): Promise { + const resolvedId = resolveAcpAgentAlias(agentId) + const registryAgent = this.getRegistryAgentOrThrow(resolvedId) + const currentState = this.getAgentRepositoryOrThrow().getAgentInstallState(registryAgent.id) + + await this.acpLaunchSpecService.uninstallRegistryAgent(registryAgent, currentState) + + const uninstalledState: AcpAgentInstallState = { + status: 'not_installed', + version: registryAgent.version, + distributionType: + this.acpLaunchSpecService.selectRegistryDistribution(registryAgent)?.type ?? undefined, + lastCheckedAt: Date.now(), + installedAt: null, + installDir: null, + error: null + } + + const updated = this.getAgentRepositoryOrThrow().clearRegistryAcpAgentInstallation( + registryAgent.id, + uninstalledState + ) + if (!updated) { + throw new Error(`ACP registry agent not found: ${registryAgent.id}`) + } + + this.handleAcpAgentsMutated([registryAgent.id]) + } + async getAcpAgentInstallStatus(agentId: string): Promise { return this.agentRepository?.getAgentInstallState(resolveAcpAgentAlias(agentId)) ?? null } diff --git a/src/renderer/settings/components/AcpSettings.vue b/src/renderer/settings/components/AcpSettings.vue index 80a11e9fe..e8a2d8f0b 100644 --- a/src/renderer/settings/components/AcpSettings.vue +++ b/src/renderer/settings/components/AcpSettings.vue @@ -125,11 +125,21 @@ {{ agent.description || t('settings.acp.builtinHint', { name: agent.name }) }} - +
+ + +
@@ -520,6 +530,40 @@ + + + + + {{ + uninstallDialog.agent + ? t('settings.acp.registryUninstallConfirm', { + name: uninstallDialog.agent.name + }) + : '' + }} + + + {{ t('settings.acp.registryUninstallDescription') }} + + + + + {{ t('common.cancel') }} + + + {{ t('settings.acp.registryUninstallAction') }} + + + + + ({ + open: false, + agent: null +}) + const parseEnvBlock = (value: string): Record => { return Object.fromEntries( value @@ -722,10 +784,10 @@ const setAgentPending = (agentId: string, pending: boolean) => { } } -const handleError = (error: unknown, description?: string) => { +const handleError = (error: unknown, description?: string, title?: string) => { console.error('[ACP] settings error:', error) toast({ - title: t('settings.acp.saveFailed'), + title: title ?? t('settings.acp.saveFailed'), description: description ?? (error instanceof Error ? error.message : t('common.error.requestFailed')), variant: 'destructive' @@ -859,6 +921,44 @@ const repairRegistryAgent = async (agent: AcpRegistryAgent) => { } } +const uninstallRegistryAgent = async (agent: AcpRegistryAgent) => { + setAgentPending(agent.id, true) + try { + await configPresenter.uninstallAcpRegistryAgent(agent.id) + await loadAcpData() + toast({ title: t('settings.acp.deleteSuccess') }) + } catch (error) { + handleError(error, undefined, t('settings.acp.registryUninstallFailed')) + } finally { + setAgentPending(agent.id, false) + } +} + +const handleRegistryUninstallDialogOpenChange = (open: boolean) => { + uninstallDialog.open = open +} + +const confirmRegistryAgentUninstall = (agent: AcpRegistryAgent) => { + uninstallDialog.agent = agent + uninstallDialog.open = true +} + +const cancelRegistryAgentUninstall = () => { + uninstallDialog.open = false + uninstallDialog.agent = null +} + +const confirmRegistryAgentUninstallAction = async () => { + const agent = uninstallDialog.agent + if (!agent) { + return + } + + uninstallDialog.open = false + await uninstallRegistryAgent(agent) + uninstallDialog.agent = null +} + const openInspector = (agentId: string, agentName: string) => { debugDialog.agentId = agentId debugDialog.agentName = agentName @@ -949,7 +1049,7 @@ const openRegistryDialog = () => { const registryActionLabel = (agent: AcpRegistryAgent) => { const status = agent.installState?.status ?? 'not_installed' - if (status === 'installed') return t('settings.acp.installState.installed') + if (status === 'installed') return t('settings.acp.registryUninstallAction') if (status === 'installing') return t('settings.acp.installState.installing') if (status === 'error') return t('settings.acp.registryRepair') return t('settings.acp.registryInstallAction') @@ -957,12 +1057,12 @@ const registryActionLabel = (agent: AcpRegistryAgent) => { const registryActionVariant = (agent: AcpRegistryAgent) => { const status = agent.installState?.status ?? 'not_installed' - return status === 'installed' ? 'outline' : 'default' + return status === 'installed' ? 'destructive' : 'default' } const registryActionIcon = (agent: AcpRegistryAgent) => { const status = agent.installState?.status ?? 'not_installed' - if (status === 'installed') return 'lucide:check' + if (status === 'installed') return 'lucide:trash-2' if (status === 'installing') return 'lucide:loader' if (status === 'error') return 'lucide:wrench' return 'lucide:download' @@ -974,13 +1074,17 @@ const registryActionSpins = (agent: AcpRegistryAgent) => { const isRegistryActionDisabled = (agent: AcpRegistryAgent) => { const status = agent.installState?.status ?? 'not_installed' - return Boolean(agentPending[agent.id]) || status === 'installing' || status === 'installed' + return Boolean(agentPending[agent.id]) || status === 'installing' } const handleRegistryCatalogAction = async (agent: AcpRegistryAgent) => { if (isRegistryActionDisabled(agent)) { return } + if ((agent.installState?.status ?? 'not_installed') === 'installed') { + await confirmRegistryAgentUninstall(agent) + return + } await installRegistryAgent(agent) } diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json index 6c3686406..a0806cc5c 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -1190,6 +1190,10 @@ "installedEmptyDescription": "Install an agent from the ACP Registry first, then configure it here.", "registryOverlayEmpty": "No agents match the current filters.", "registryInstallAction": "Install", + "registryUninstallAction": "Afinstaller", + "registryUninstallConfirm": "Vil du afinstallere agenten \"{name}\"?", + "registryUninstallDescription": "DeepChat deaktiverer agenten og rydder lokalt administrerede installationsdata, når det er relevant. Du kan installere den igen senere fra ACP Registry.", + "registryUninstallFailed": "Kunne ikke afinstallere agenten", "registryInstallTitle": "ACP Registry", "registryInstallDescription": "Search, filter, and install ACP agents. Configure them back in settings after installation.", "registryLearnMore": "Learn More", diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index e232e5a33..f3df4c6fb 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -1171,6 +1171,10 @@ "installedEmptyDescription": "Install an agent from the ACP Registry first, then configure it here.", "registryOverlayEmpty": "No agents match the current filters.", "registryInstallAction": "Install", + "registryUninstallAction": "Uninstall", + "registryUninstallConfirm": "Uninstall \"{name}\"?", + "registryUninstallDescription": "DeepChat will disable this agent and clear locally managed install data when applicable. You can install it again later from the ACP Registry.", + "registryUninstallFailed": "Failed to uninstall agent", "registryInstallTitle": "ACP Registry", "registryInstallDescription": "Search, filter, and install ACP agents. Configure them back in settings after installation.", "registryLearnMore": "Learn More", diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json index 3331166ed..b07877182 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -1270,6 +1270,10 @@ "installedEmptyDescription": "ابتدا یک عامل را از ACP Registry نصب کنید، سپس آن را اینجا پیکربندی کنید.", "registryOverlayEmpty": "هیچ عاملی با فیلترهای فعلی مطابقت ندارد.", "registryInstallAction": "نصب", + "registryUninstallAction": "حذف نصب", + "registryUninstallConfirm": "عامل «{name}» حذف نصب شود؟", + "registryUninstallDescription": "DeepChat این عامل را غیرفعال می‌کند و در صورت وجود، داده‌های نصبِ مدیریت‌شدهٔ محلی را پاک می‌کند. بعداً می‌توانید آن را دوباره از ACP Registry نصب کنید.", + "registryUninstallFailed": "حذف نصب عامل ناموفق بود", "registryInstallTitle": "ACP Registry", "registryInstallDescription": "عامل‌های ACP را جست‌وجو، فیلتر و نصب کنید. پس از نصب برای پیکربندی به تنظیمات برگردید.", "registryLearnMore": "اطلاعات بیشتر", diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index e5ff8a06b..5f77467b8 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -1270,6 +1270,10 @@ "installedEmptyDescription": "Installez d’abord un agent depuis l’ACP Registry, puis configurez-le ici.", "registryOverlayEmpty": "Aucun agent ne correspond aux filtres actuels.", "registryInstallAction": "Installer", + "registryUninstallAction": "Désinstaller", + "registryUninstallConfirm": "Désinstaller l’agent « {name} » ?", + "registryUninstallDescription": "DeepChat désactivera cet agent et supprimera, le cas échéant, les données d’installation gérées localement. Vous pourrez le réinstaller plus tard depuis l’ACP Registry.", + "registryUninstallFailed": "Échec de la désinstallation de l’agent", "registryInstallTitle": "ACP Registry", "registryInstallDescription": "Recherchez, filtrez et installez des agents ACP. Revenez ensuite dans les réglages pour les configurer.", "registryLearnMore": "En savoir plus", diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json index 111d1c59c..c2d4c0699 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -1244,6 +1244,10 @@ "installedEmptyDescription": "התקן תחילה סוכן מ-ACP Registry, ואז הגדר אותו כאן.", "registryOverlayEmpty": "אין סוכנים שתואמים למסננים הנוכחיים.", "registryInstallAction": "התקן", + "registryUninstallAction": "הסר התקנה", + "registryUninstallConfirm": "להסיר את ההתקנה של הסוכן \"{name}\"?", + "registryUninstallDescription": "DeepChat ישבית את הסוכן הזה, וכשיש צורך גם ינקה נתוני התקנה שמנוהלים מקומית. אפשר להתקין אותו שוב מאוחר יותר דרך ACP Registry.", + "registryUninstallFailed": "הסרת ההתקנה של הסוכן נכשלה", "registryInstallTitle": "ACP Registry", "registryInstallDescription": "חיפוש, סינון והתקנה של סוכני ACP. לאחר ההתקנה חזור להגדרות כדי להגדיר אותם.", "registryLearnMore": "מידע נוסף", diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json index 01ff2eae0..7cbdcae9a 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -1270,6 +1270,10 @@ "installedEmptyDescription": "まず ACP Registry から Agent をインストールし、その後ここで設定してください。", "registryOverlayEmpty": "現在の絞り込み条件に一致する Agent はありません。", "registryInstallAction": "インストール", + "registryUninstallAction": "アンインストール", + "registryUninstallConfirm": "「{name}」をアンインストールしますか?", + "registryUninstallDescription": "DeepChat はこのエージェントを無効化し、該当する場合はローカルで管理しているインストールデータを削除します。必要になったら ACP Registry から再インストールできます。", + "registryUninstallFailed": "エージェントのアンインストールに失敗しました", "registryInstallTitle": "ACP Registry", "registryInstallDescription": "ACP Agent を検索、絞り込み、インストールします。インストール後は設定画面に戻って構成してください。", "registryLearnMore": "詳しく見る", diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index 81d19ef06..1898db688 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -1270,6 +1270,10 @@ "installedEmptyDescription": "Install an agent from the ACP Registry first, then configure it here.", "registryOverlayEmpty": "No agents match the current filters.", "registryInstallAction": "Install", + "registryUninstallAction": "제거", + "registryUninstallConfirm": "\"{name}\" 에이전트를 제거할까요?", + "registryUninstallDescription": "DeepChat이 이 에이전트를 비활성화하고, 해당하는 경우 로컬에서 관리하는 설치 데이터를 정리합니다. 나중에 ACP Registry에서 다시 설치할 수 있습니다.", + "registryUninstallFailed": "에이전트를 제거하지 못했습니다", "registryInstallTitle": "ACP Registry", "registryInstallDescription": "Search, filter, and install ACP agents. Configure them back in settings after installation.", "registryLearnMore": "Learn More", diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json index 9478b6a9c..8f8dcb6f8 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -1270,6 +1270,10 @@ "installedEmptyDescription": "Install an agent from the ACP Registry first, then configure it here.", "registryOverlayEmpty": "No agents match the current filters.", "registryInstallAction": "Install", + "registryUninstallAction": "Desinstalar", + "registryUninstallConfirm": "Desinstalar o agente \"{name}\"?", + "registryUninstallDescription": "O DeepChat vai desativar este agente e limpar, quando houver, os dados de instalação gerenciados localmente. Depois, você poderá instalá-lo novamente pelo ACP Registry.", + "registryUninstallFailed": "Falha ao desinstalar o agente", "registryInstallTitle": "ACP Registry", "registryInstallDescription": "Search, filter, and install ACP agents. Configure them back in settings after installation.", "registryLearnMore": "Learn More", diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index 00fffd7ec..ebbcc5f9e 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -1270,6 +1270,10 @@ "installedEmptyDescription": "Install an agent from the ACP Registry first, then configure it here.", "registryOverlayEmpty": "No agents match the current filters.", "registryInstallAction": "Install", + "registryUninstallAction": "Удалить", + "registryUninstallConfirm": "Удалить агента «{name}»?", + "registryUninstallDescription": "DeepChat отключит этого агента и, если применимо, очистит локально управляемые данные установки. При необходимости его можно снова установить из ACP Registry.", + "registryUninstallFailed": "Не удалось удалить агента", "registryInstallTitle": "ACP Registry", "registryInstallDescription": "Search, filter, and install ACP agents. Configure them back in settings after installation.", "registryLearnMore": "Learn More", diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index 577d45cb3..e884dc14a 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -1175,6 +1175,10 @@ "registryRefresh": "刷新 Registry", "registryRepair": "修复", "registryInstallAction": "安装", + "registryUninstallAction": "卸载", + "registryUninstallConfirm": "卸载“{name}”?", + "registryUninstallDescription": "DeepChat 会禁用该 Agent,并在适用时清理本地托管的安装数据,之后仍可在 ACP Registry 中重新安装。", + "registryUninstallFailed": "卸载 Agent 失败", "registryInstallTitle": "ACP Registry", "registryInstallDescription": "搜索、筛选并安装 ACP Agent。安装完成后回到设置页配置。", "registryLearnMore": "了解更多", diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index 009313983..d6d763547 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -1270,6 +1270,10 @@ "installedEmptyDescription": "先從 ACP Registry 安裝 Agent,安裝後才會在這裡顯示可配置介面。", "registryOverlayEmpty": "目前篩選條件下沒有可安裝的 Agent。", "registryInstallAction": "安裝", + "registryUninstallAction": "卸載", + "registryUninstallConfirm": "卸載「{name}」?", + "registryUninstallDescription": "DeepChat 會停用這個 Agent,並在適用時清理本機代管的安裝資料,之後仍可在 ACP Registry 重新安裝。", + "registryUninstallFailed": "卸載 Agent 失敗", "registryInstallTitle": "ACP Registry", "registryInstallDescription": "搜尋、篩選並安裝 ACP Agent。安裝完成後回到設定頁配置。", "registryLearnMore": "了解更多", diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index 62fb9d8a7..f0ab28bbf 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -1270,6 +1270,10 @@ "installedEmptyDescription": "先從 ACP Registry 安裝 Agent,安裝後才會在這裡顯示可配置介面。", "registryOverlayEmpty": "目前篩選條件下沒有可安裝的 Agent。", "registryInstallAction": "安裝", + "registryUninstallAction": "卸載", + "registryUninstallConfirm": "卸載「{name}」?", + "registryUninstallDescription": "DeepChat 會停用這個 Agent,並在適用時清理本機代管的安裝資料,之後仍可在 ACP Registry 重新安裝。", + "registryUninstallFailed": "卸載 Agent 失敗", "registryInstallTitle": "ACP Registry", "registryInstallDescription": "搜尋、篩選並安裝 ACP Agent。安裝完成後回到設定頁配置。", "registryLearnMore": "了解更多", diff --git a/src/renderer/src/stores/ui/agent.ts b/src/renderer/src/stores/ui/agent.ts index adc6491eb..54d95c1f2 100644 --- a/src/renderer/src/stores/ui/agent.ts +++ b/src/renderer/src/stores/ui/agent.ts @@ -57,11 +57,11 @@ export const useAgentStore = defineStore('agent', () => { config: a.config, installState: a.installState ?? null })) - if ( - selectedAgentId.value !== null && - !agents.value.some((agent) => agent.id === selectedAgentId.value) - ) { - selectedAgentId.value = null + if (selectedAgentId.value !== null) { + const selectedAgent = agents.value.find((agent) => agent.id === selectedAgentId.value) + if (!selectedAgent || !selectedAgent.enabled) { + selectedAgentId.value = null + } } } catch (e) { error.value = `Failed to load agents: ${e}` diff --git a/src/shared/types/presenters/legacy.presenters.d.ts b/src/shared/types/presenters/legacy.presenters.d.ts index c37201851..5c8b3d0a7 100644 --- a/src/shared/types/presenters/legacy.presenters.d.ts +++ b/src/shared/types/presenters/legacy.presenters.d.ts @@ -642,6 +642,7 @@ export interface IConfigPresenter { setAcpAgentEnvOverride(agentId: string, env: Record): Promise ensureAcpAgentInstalled(agentId: string): Promise repairAcpAgent(agentId: string): Promise + uninstallAcpRegistryAgent(agentId: string): Promise getAcpAgentInstallStatus(agentId: string): Promise listManualAcpAgents(): Promise addManualAcpAgent( diff --git a/src/types/i18n.d.ts b/src/types/i18n.d.ts index 38503f87d..3c40aafab 100644 --- a/src/types/i18n.d.ts +++ b/src/types/i18n.d.ts @@ -1995,6 +1995,10 @@ declare module 'vue-i18n' { registryRefresh: string registryRepair: string registryInstallAction: string + registryUninstallAction: string + registryUninstallConfirm: string + registryUninstallDescription: string + registryUninstallFailed: string registryInstallTitle: string registryInstallDescription: string registryLearnMore: string diff --git a/test/main/presenter/agentRepository.test.ts b/test/main/presenter/agentRepository.test.ts new file mode 100644 index 000000000..1df9343c6 --- /dev/null +++ b/test/main/presenter/agentRepository.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { AgentRepository } from '../../../src/main/presenter/agentRepository' + +describe('AgentRepository', () => { + it('clears registry ACP installation state without deleting the row', () => { + const row = { + id: 'codex-acp', + agent_type: 'acp' as const, + source: 'registry' as const, + name: 'Codex CLI', + enabled: 1, + protected: 0, + description: null, + icon: null, + avatar_json: null, + config_json: '{}', + state_json: JSON.stringify({ + envOverride: { + OPENAI_API_KEY: 'secret' + }, + installState: { + status: 'installed', + version: '0.10.0', + installDir: 'C:\\temp\\codex-acp' + } + }), + created_at: Date.now(), + updated_at: Date.now() + } + + const sqlitePresenter = { + agentsTable: { + get: (id: string) => (id === row.id ? row : undefined), + update: (_id: string, input: { enabled?: boolean; stateJson?: string | null }) => { + if (typeof input.enabled === 'boolean') { + row.enabled = input.enabled ? 1 : 0 + } + if (typeof input.stateJson === 'string') { + row.state_json = input.stateJson + } + } + } + } + + const repository = new AgentRepository(sqlitePresenter as never) + const updated = repository.clearRegistryAcpAgentInstallation('codex-acp', { + status: 'not_installed', + version: '0.10.0', + distributionType: 'binary', + installDir: null, + installedAt: null, + error: null + }) + + expect(updated).toBe(true) + expect(row.enabled).toBe(0) + expect(JSON.parse(row.state_json ?? '{}')).toEqual({ + envOverride: { + OPENAI_API_KEY: 'secret' + }, + installState: { + status: 'not_installed', + version: '0.10.0', + distributionType: 'binary', + installDir: null, + installedAt: null, + error: null + } + }) + }) +}) diff --git a/test/main/presenter/configPresenter/acpLaunchSpecService.test.ts b/test/main/presenter/configPresenter/acpLaunchSpecService.test.ts index 3b10f17c7..d4c94a5d6 100644 --- a/test/main/presenter/configPresenter/acpLaunchSpecService.test.ts +++ b/test/main/presenter/configPresenter/acpLaunchSpecService.test.ts @@ -121,4 +121,88 @@ describe('AcpLaunchSpecService', () => { ) ).rejects.toThrow('Unsafe ACP registry agent id') }) + + it('uninstalls binary registry agents by removing the install directory', async () => { + const service = createService() + const platformMap: Record = { + darwin: 'darwin', + linux: 'linux', + win32: 'windows' + } + const archMap: Record = { + arm64: 'aarch64', + x64: 'x86_64' + } + const platformKey = `${platformMap[process.platform]}-${archMap[process.arch]}` + + const agent = { + id: 'codex-acp', + name: 'Codex CLI', + version: '0.10.0', + distribution: { + binary: { + [platformKey]: { + archive: 'https://example.com/codex.zip', + cmd: './codex-acp' + } + } + }, + source: 'registry' as const, + enabled: false + } + + const installDir = path.join(tempDirs[0], 'agents', agent.id, agent.version) + fs.mkdirSync(installDir, { recursive: true }) + fs.writeFileSync(path.join(installDir, 'codex-acp'), 'binary') + + await service.uninstallRegistryAgent(agent, { + status: 'installed', + distributionType: 'binary', + version: agent.version, + installDir + }) + + expect(fs.existsSync(installDir)).toBeFalsy() + expect(fs.existsSync(path.join(tempDirs[0], 'agents', agent.id))).toBeFalsy() + }) + + it('rejects uninstall paths outside the managed install root', async () => { + const service = createService() + const platformMap: Record = { + darwin: 'darwin', + linux: 'linux', + win32: 'windows' + } + const archMap: Record = { + arm64: 'aarch64', + x64: 'x86_64' + } + const platformKey = `${platformMap[process.platform]}-${archMap[process.arch]}` + + await expect( + service.uninstallRegistryAgent( + { + id: 'codex-acp', + name: 'Codex CLI', + version: '0.10.0', + distribution: { + binary: { + [platformKey]: { + archive: 'https://example.com/codex.zip', + cmd: './codex-acp' + } + } + }, + source: 'registry', + enabled: false + }, + { + status: 'installed', + distributionType: 'binary', + version: '0.10.0', + installDir: path.join(os.tmpdir(), 'outside-deepchat-acp') + } + ) + ).rejects.toThrow('Unsafe ACP install directory for uninstall') + }) }) diff --git a/test/renderer/components/AcpSettings.test.ts b/test/renderer/components/AcpSettings.test.ts new file mode 100644 index 000000000..066e46a3f --- /dev/null +++ b/test/renderer/components/AcpSettings.test.ts @@ -0,0 +1,257 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, inject, provide } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' + +const passthrough = (name: string) => + defineComponent({ + name, + template: '
' + }) + +const ButtonStub = defineComponent({ + name: 'ButtonStub', + props: { + disabled: { + type: Boolean, + default: false + } + }, + template: '' +}) + +const InputStub = defineComponent({ + name: 'InputStub', + props: { + modelValue: { + type: String, + default: '' + } + }, + emits: ['update:modelValue'], + template: + '' +}) + +const TextareaStub = defineComponent({ + name: 'TextareaStub', + props: { + modelValue: { + type: String, + default: '' + } + }, + emits: ['update:modelValue'], + template: + '