From 027e59107c93b0c379094b22429368b0dab22389 Mon Sep 17 00:00:00 2001 From: kong <135376906+3316891527@users.noreply.github.com> Date: Sat, 5 Sep 2026 07:57:33 +0000 Subject: [PATCH 1/2] feat(packages): add per-plugin update check with market detail jump --- .../packages/dialogs/PackageDetailsDialog.kt | 134 +++++++++++++++++- .../packages/market/MarketInstallMarker.kt | 12 ++ .../packages/screens/PackageManagerScreen.kt | 6 + .../operit/ui/main/screens/OperitScreens.kt | 3 + app/src/main/res/values-en/strings.xml | 8 ++ app/src/main/res/values-es/strings.xml | 8 ++ app/src/main/res/values-id/strings.xml | 8 ++ app/src/main/res/values-ko/strings.xml | 8 ++ app/src/main/res/values-ms/strings.xml | 8 ++ app/src/main/res/values-pt-rBR/strings.xml | 8 ++ app/src/main/res/values-ro/strings.xml | 8 ++ app/src/main/res/values/strings.xml | 8 ++ 12 files changed, 218 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/packages/dialogs/PackageDetailsDialog.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/packages/dialogs/PackageDetailsDialog.kt index 3607080665..a391fe9f14 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/packages/dialogs/PackageDetailsDialog.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/packages/dialogs/PackageDetailsDialog.kt @@ -27,11 +27,25 @@ import com.ai.assistance.operit.R import com.ai.assistance.operit.core.tools.PackageTool import com.ai.assistance.operit.core.tools.ToolPackage import com.ai.assistance.operit.core.tools.packTool.PackageManager +import com.ai.assistance.operit.data.api.MarketStatsApiService +import com.ai.assistance.operit.data.api.MarketV2Entry import com.ai.assistance.operit.ui.common.icons.rememberLogoPainter +import com.ai.assistance.operit.ui.features.packages.market.readMarketInstallMarkerForPackage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +private sealed interface PackageUpdateCheckState { + data object UpToDate : PackageUpdateCheckState + data class UpdateAvailable( + val entry: MarketV2Entry, + val latestVersion: String + ) : PackageUpdateCheckState + data object NotFromMarket : PackageUpdateCheckState + data object EntryMissing : PackageUpdateCheckState + data object Failed : PackageUpdateCheckState +} + @Composable fun PackageDetailsDialog( packageName: String, @@ -41,11 +55,60 @@ fun PackageDetailsDialog( onRunScript: (String, PackageTool) -> Unit, onOpenToolPkgPluginConfig: (String, String, String, Boolean) -> Unit, onDismiss: () -> Unit, - onPackageDeleted: () -> Unit + onPackageDeleted: () -> Unit, + onOpenMarketDetail: (MarketV2Entry) -> Unit ) { val context = LocalContext.current var showDeleteConfirmDialog by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() + var updateCheckState by remember(packageName) { mutableStateOf(null) } + var isCheckingUpdate by remember { mutableStateOf(false) } + + fun runUpdateCheck() { + if (isCheckingUpdate) return + isCheckingUpdate = true + updateCheckState = null + scope.launch { + try { + val marker = + withContext(Dispatchers.IO) { + readMarketInstallMarkerForPackage(packageManager, packageName) + } + if (marker == null) { + updateCheckState = PackageUpdateCheckState.NotFromMarket + return@launch + } + val entryResult = + withContext(Dispatchers.IO) { + MarketStatsApiService().getEntry(marker.entryId) + } + if (entryResult.isFailure) { + updateCheckState = PackageUpdateCheckState.Failed + return@launch + } + val entry = entryResult.getOrNull() + if (entry == null) { + updateCheckState = PackageUpdateCheckState.EntryMissing + return@launch + } + val latest = entry.latestVersion + if (latest == null || latest.id.isBlank() || latest.id == marker.versionId) { + updateCheckState = PackageUpdateCheckState.UpToDate + } else { + updateCheckState = + PackageUpdateCheckState.UpdateAvailable( + entry = entry, + latestVersion = latest.version.ifBlank { latest.id } + ) + } + } catch (error: Exception) { + AppLogger.e("PackageDetailsDialog", "Update check failed", error) + updateCheckState = PackageUpdateCheckState.Failed + } finally { + isCheckingUpdate = false + } + } + } val resolvedPackage by produceState(initialValue = toolPackage, packageName, toolPackage) { value = toolPackage @@ -185,6 +248,56 @@ fun PackageDetailsDialog( ) } + val currentUpdateCheckState = updateCheckState + if (currentUpdateCheckState != null) { + AlertDialog( + onDismissRequest = { updateCheckState = null }, + title = { Text(stringResource(R.string.pkg_check_update)) }, + text = { + Text( + when (currentUpdateCheckState) { + is PackageUpdateCheckState.UpToDate -> + stringResource(R.string.pkg_check_update_up_to_date) + is PackageUpdateCheckState.UpdateAvailable -> + stringResource( + R.string.pkg_check_update_available, + currentUpdateCheckState.latestVersion + ) + PackageUpdateCheckState.NotFromMarket -> + stringResource(R.string.pkg_check_update_not_market) + PackageUpdateCheckState.EntryMissing -> + stringResource(R.string.pkg_check_update_entry_missing) + PackageUpdateCheckState.Failed -> + stringResource(R.string.pkg_check_update_failed) + } + ) + }, + confirmButton = { + if (currentUpdateCheckState is PackageUpdateCheckState.UpdateAvailable) { + Button( + onClick = { + updateCheckState = null + onOpenMarketDetail(currentUpdateCheckState.entry) + } + ) { + Text(stringResource(R.string.pkg_check_update_go)) + } + } else { + Button(onClick = { updateCheckState = null }) { + Text(stringResource(R.string.pkg_ok)) + } + } + }, + dismissButton = { + if (currentUpdateCheckState is PackageUpdateCheckState.UpdateAvailable) { + TextButton(onClick = { updateCheckState = null }) { + Text(stringResource(R.string.pkg_cancel)) + } + } + } + ) + } + Dialog(onDismissRequest = onDismiss) { Surface( modifier = Modifier.fillMaxWidth().heightIn(max = 600.dp), @@ -621,6 +734,25 @@ fun PackageDetailsDialog( horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) ) { if (metaPackage != null && !metaPackage.isBuiltIn) { + OutlinedButton( + onClick = { runUpdateCheck() }, + enabled = !isCheckingUpdate + ) { + if (isCheckingUpdate) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp + ) + } else { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.pkg_check_update)) + } OutlinedButton( onClick = { showDeleteConfirmDialog = true }, colors = ButtonDefaults.outlinedButtonColors( diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/packages/market/MarketInstallMarker.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/packages/market/MarketInstallMarker.kt index a4e30a0819..8d764d47b9 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/packages/market/MarketInstallMarker.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/packages/market/MarketInstallMarker.kt @@ -66,6 +66,18 @@ fun findInstalledMarketMarkerRoot( return findInstalledMarketMarkerRoots(context, packageManager, entryId).firstOrNull() } +fun readMarketInstallMarkerForPackage( + packageManager: PackageManager, + packageName: String +): MarketInstallMarker? { + return try { + readMarketInstallMarker(artifactMarketMarkerRoot(packageManager, packageName)) + } catch (error: Exception) { + AppLogger.w(TAG, "Failed to read market install marker for package: $packageName", error) + null + } +} + fun findInstalledMarketMarkerRoots( context: Context, packageManager: PackageManager, diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/packages/screens/PackageManagerScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/packages/screens/PackageManagerScreen.kt index 9842b4fe2d..bcacf88e39 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/packages/screens/PackageManagerScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/packages/screens/PackageManagerScreen.kt @@ -1,6 +1,7 @@ package com.ai.assistance.operit.ui.features.packages.screens import com.ai.assistance.operit.util.AppLogger +import com.ai.assistance.operit.data.api.MarketV2Entry import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi @@ -130,6 +131,7 @@ fun PackageManagerScreen( onNavigateToArtifactMarket: () -> Unit = {}, onStartPluginCreation: (PluginCreationIntent) -> Unit = {}, onOpenToolPkgPluginConfig: (String, String, String, Boolean) -> Unit = { _, _, _, _ -> }, + onOpenMarketDetail: (MarketV2Entry) -> Unit = {}, ) { val context = LocalContext.current val toolHandler = remember { AIToolHandler.getInstance(context) } @@ -948,6 +950,10 @@ fun PackageManagerScreen( showDetails = false onOpenToolPkgPluginConfig(containerPackageName, uiModuleId, title, keepAlive) }, + onOpenMarketDetail = { entry -> + showDetails = false + onOpenMarketDetail(entry) + }, onDismiss = { showDetails = false scope.launch { diff --git a/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt b/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt index da8da01779..be9987343e 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt @@ -201,6 +201,9 @@ sealed class Screen( onNavigateToMCPMarket = { navigateTo(Market(MarketHomeTab.ALL)) }, onNavigateToSkillMarket = { navigateTo(Market(MarketHomeTab.ALL)) }, onNavigateToArtifactMarket = { navigateTo(Market(MarketHomeTab.ALL)) }, + onOpenMarketDetail = { entry -> + navigateTo(MarketEntryDetail(entry = entry)) + }, onStartPluginCreation = { intent -> PendingChatDraftHandler.setPendingDraft(intent.toPrompt(context)) navigateTo(AiChat) diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index da3ae8664a..3459cee518 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -3057,6 +3057,14 @@ Kotlin UI automation function execution (route/config) has been removed. Automation Feature Close + Check for updates + You are on the latest version + New version %1$s available. Go to the plugin market to update. + Go to update + This plugin was not installed from the market, so updates cannot be checked. + This plugin was not found in the market. It may have been removed. + Failed to check for updates. Please check your network and try again. + OK Command Result Copied to clipboard Copy failed: %1$s diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 91d19cc84e..d0feacd37b 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -5363,6 +5363,14 @@ Ahora puede usar el modo AutoGLM en la interfaz de conversación. ¿Estás seguro de que deseas eliminar el paquete "%1$s"? Esta acción no se puede deshacer. Eliminar Cerrar + Buscar actualizaciones + Ya tienes la última versión + Nueva versión %1$s disponible. Ve al mercado de plugins para actualizar. + Ir a actualizar + Este plugin no se instaló desde el mercado, no se pueden buscar actualizaciones. + No se encontró este plugin en el mercado. Es posible que se haya retirado. + No se pudo buscar actualizaciones. Comprueba tu red e inténtalo de nuevo. + Aceptar Lista de herramientas No hay herramientas disponibles Buscar plugin o autor diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 864f535afd..8417aa3f24 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -5296,6 +5296,14 @@ Sekarang Anda dapat menggunakan mode AutoGLM di antarmuka percakapan. Yakin ingin menghapus paket "%1$s"? Tindakan ini tidak dapat dibatalkan. Hapus Tutup + Periksa pembaruan + Anda sudah menggunakan versi terbaru + Tersedia versi baru %1$s. Buka pasar plugin untuk memperbarui. + Pergi ke pembaruan + Plugin ini tidak diinstal dari pasar, tidak dapat memeriksa pembaruan. + Plugin tidak ditemukan di pasar. Mungkin sudah dihapus. + Gagal memeriksa pembaruan. Periksa jaringan Anda dan coba lagi. + Oke Daftar alat Belum ada alat yang tersedia Cari nama plugin, deskripsi, penulis... diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index bef17c9d23..3dede7dfd3 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -5494,6 +5494,14 @@ 정말로 "%1$s" 패키지를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다. 삭제 닫기 + 업데이트 확인 + 최신 버전입니다 + 새 버전 %1$s이(가) 있습니다. 플러그인 마켓에서 업데이트하세요. + 업데이트하러 가기 + 이 플러그인은 마켓에서 설치되지 않아 업데이트를 확인할 수 없습니다. + 마켓에서 이 플러그인을 찾을 수 없습니다. 삭제되었을 수 있습니다. + 업데이트 확인 실패. 네트워크를 확인한 후 다시 시도하세요. + 확인 도구 목록 사용 가능한 도구 없음 플러그인 또는 작성자 검색 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index e471122d20..e78a084378 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -5294,6 +5294,14 @@ Kini anda boleh menggunakan mod AutoGLM di antara muka perbualan. Adakah anda pasti mahu memadam pakej "%1$s"? Tindakan ini tidak boleh dibatalkan. Padam Tutup + Semak kemas kini + Anda sudah menggunakan versi terkini + Versi baharu %1$s tersedia. Pergi ke pasaran plugin untuk mengemas kini. + Pergi kemas kini + Plugin ini tidak dipasang dari pasaran, tidak dapat menyemak kemas kini. + Plugin tidak ditemui di pasaran. Ia mungkin telah dialih keluar. + Gagal menyemak kemas kini. Sila semak rangkaian anda dan cuba lagi. + OK Pelengkap dan pembinaan semula vektor gagal: %1$s Sila konfigurasi dan simpan perkhidmatan vektor yang tersedia terlebih dahulu diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 31e3e667ec..73a6696a78 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -5065,6 +5065,14 @@ Agora é possível usar o modo AutoGLM na interface de diálogo. Tem certeza de que deseja excluir o pacote "%1$s"? Esta ação não pode ser desfeita. excluir encerramento + Verificar atualizações + Você já está na versão mais recente + Nova versão %1$s disponível. Acesse o mercado de plugins para atualizar. + Ir para atualização + Este plugin não foi instalado pelo mercado, não é possível verificar atualizações. + Este plugin não foi encontrado no mercado. Ele pode ter sido removido. + Falha ao verificar atualizações. Verifique sua rede e tente novamente. + OK Lista de ferramentas Nenhuma ferramenta disponível ainda Pesquise o nome do plugin, descrição, autor... diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 25f57b895c..2cec891600 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -6260,6 +6260,14 @@ Acum poate fi utilizat în interfața de dialog AutoGLM A devenit un model.Sunteți sigur că doriți să ștergeți pachetul „%1$s" ? Această operațiune nu poate fi anulată. Ștergere Închide + Verifică actualizări + Aveți deja cea mai recentă versiune + Este disponibilă versiunea nouă %1$s. Accesați piața de pluginuri pentru actualizare. + Mergi la actualizare + Acest plugin nu a fost instalat din piață, nu se pot verifica actualizările. + Pluginul nu a fost găsit în piață. Este posibil să fi fost retras. + Verificarea actualizărilor a eșuat. Verificați rețeaua și încercați din nou. + OK Lista de instrumente Nu există instrumente disponibile în acest moment diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54019940f0..c2f822713d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6447,6 +6447,14 @@ 确定要删除包 "%1$s" 吗?此操作无法撤销。 删除 关闭 + 检查更新 + 当前已是最新版本 + 发现新版本 %1$s,可前往插件市场更新 + 前往更新 + 该插件不是从市场安装的,无法检查更新 + 未在市场中找到该插件,可能已被下架 + 检查更新失败,请检查网络后重试 + 确定 工具列表 暂无可用工具 From d7e3a4510915f8fed8427734c5a84a715f15ef98 Mon Sep 17 00:00:00 2001 From: kong <135376906+3316891527@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:45:45 +0000 Subject: [PATCH 2/2] feat(packages): add market update check for MCP servers and skills --- .../dialogs/MCPServerDetailsDialog.kt | 8 +- .../actions/MCPServerDetailsActions.kt | 17 ++- .../packages/market/MarketInstallMarker.kt | 29 ++++ .../market/MarketUpdateCheckButton.kt | 124 ++++++++++++++++++ .../packages/market/MarketUpdateChecker.kt | 47 +++++++ .../packages/screens/MCPConfigScreen.kt | 6 + .../packages/screens/PackageManagerScreen.kt | 2 + .../packages/screens/SkillConfigScreen.kt | 25 +++- 8 files changed, 250 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/ai/assistance/operit/ui/features/packages/market/MarketUpdateCheckButton.kt create mode 100644 app/src/main/java/com/ai/assistance/operit/ui/features/packages/market/MarketUpdateChecker.kt diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/packages/components/dialogs/MCPServerDetailsDialog.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/packages/components/dialogs/MCPServerDetailsDialog.kt index 4fcee0eed7..9c06a0f11a 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/packages/components/dialogs/MCPServerDetailsDialog.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/packages/components/dialogs/MCPServerDetailsDialog.kt @@ -27,6 +27,7 @@ import com.ai.assistance.operit.ui.features.packages.components.dialogs.content. import com.ai.assistance.operit.ui.features.packages.components.dialogs.content.MCPServerDetailsContent import com.ai.assistance.operit.ui.features.packages.components.dialogs.header.MCPServerDetailsHeader import com.ai.assistance.operit.ui.features.packages.components.dialogs.tabs.MCPServerDetailsTabs +import com.ai.assistance.operit.data.api.MarketV2Entry import com.ai.assistance.operit.data.mcp.MCPLocalServer /** @@ -52,7 +53,8 @@ fun MCPServerDetailsDialog( pluginConfig: String = "", onSaveConfig: () -> Unit = {}, onUpdateConfig: (String) -> Unit = {}, - mdFontSize: Float = 14f + mdFontSize: Float = 14f, + onOpenMarketDetail: (MarketV2Entry) -> Unit = {} ) { val isInstalled = server.isInstalled val supportsLocalConfigEditing = server.type != "remote" @@ -135,7 +137,9 @@ fun MCPServerDetailsDialog( server = server, isInstalled = isInstalled, onInstall = onInstall, - onUninstall = onUninstall + onUninstall = onUninstall, + installedPath = installedPath, + onOpenMarketDetail = onOpenMarketDetail ) } } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/packages/components/dialogs/actions/MCPServerDetailsActions.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/packages/components/dialogs/actions/MCPServerDetailsActions.kt index 1e3c4ae178..1e393f8c2f 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/packages/components/dialogs/actions/MCPServerDetailsActions.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/packages/components/dialogs/actions/MCPServerDetailsActions.kt @@ -23,7 +23,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.ai.assistance.operit.R +import com.ai.assistance.operit.data.api.MarketV2Entry import com.ai.assistance.operit.data.mcp.MCPLocalServer +import com.ai.assistance.operit.ui.features.packages.market.MarketUpdateCheckButton +import com.ai.assistance.operit.ui.features.packages.market.readMarketInstallMarkerForMcp /** * Actions component for the MCP server details diaAppLogger. @@ -38,10 +41,11 @@ fun MCPServerDetailsActions( server: MCPLocalServer.PluginMetadata, isInstalled: Boolean, onInstall: (MCPLocalServer.PluginMetadata) -> Unit, - onUninstall: (MCPLocalServer.PluginMetadata) -> Unit + onUninstall: (MCPLocalServer.PluginMetadata) -> Unit, + installedPath: String? = null, + onOpenMarketDetail: (MarketV2Entry) -> Unit = {} ) { val context = LocalContext.current - Row( modifier = Modifier .fillMaxWidth() @@ -69,7 +73,14 @@ fun MCPServerDetailsActions( Text(text = stringResource(R.string.repo)) } } - + // 市场更新检查(仅已安装时可用) + if (isInstalled) { + MarketUpdateCheckButton( + markerProvider = { readMarketInstallMarkerForMcp(context, server.id, installedPath) }, + onOpenMarketDetail = onOpenMarketDetail, + modifier = Modifier.weight(1f) + ) + } // Install/Uninstall button if (isInstalled) { Button( diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/packages/market/MarketInstallMarker.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/packages/market/MarketInstallMarker.kt index 8d764d47b9..c8643bb6a8 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/packages/market/MarketInstallMarker.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/packages/market/MarketInstallMarker.kt @@ -78,6 +78,35 @@ fun readMarketInstallMarkerForPackage( } } +/** 读取技能安装目录下的市场安装标记(无则返回 null) */ +fun readMarketInstallMarkerForSkill(skillDirectory: File): MarketInstallMarker? { + return try { + readMarketInstallMarker(skillDirectory) + } catch (error: Exception) { + AppLogger.w(TAG, "Failed to read market install marker for skill: ${skillDirectory.absolutePath}", error) + null + } +} + +/** + * 读取 MCP 的市场安装标记。MCP 有两种安装形态: + * 1) 纯配置安装:标记写在 market_install_markers/mcp_config/{serverId}; + * 2) 物理安装:标记写在插件安装目录(installedPath)。 + * 任一位置读到即返回。 + */ +fun readMarketInstallMarkerForMcp(context: Context, serverId: String, installedPath: String?): MarketInstallMarker? { + try { + readMarketInstallMarker(mcpConfigMarketMarkerRoot(context, serverId))?.let { return it } + installedPath + ?.trim() + ?.takeIf { it.isNotBlank() } + ?.let { path -> readMarketInstallMarker(File(path))?.let { return it } } + } catch (error: Exception) { + AppLogger.w(TAG, "Failed to read market install marker for MCP: $serverId", error) + } + return null +} + fun findInstalledMarketMarkerRoots( context: Context, packageManager: PackageManager, diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/packages/market/MarketUpdateCheckButton.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/packages/market/MarketUpdateCheckButton.kt new file mode 100644 index 0000000000..2ceedabdbc --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/packages/market/MarketUpdateCheckButton.kt @@ -0,0 +1,124 @@ +package com.ai.assistance.operit.ui.features.packages.market + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.ai.assistance.operit.R +import com.ai.assistance.operit.data.api.MarketV2Entry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * 市场检查更新按钮 + 结果弹窗(包 / MCP / 技能三类界面共用)。 + * @param markerProvider 读取当前安装项市场安装标记的挂起函数;返回 null 视为非市场来源 + * @param onOpenMarketDetail 有更新时点击"前往更新"跳转市场详情页 + */ +@Composable +fun MarketUpdateCheckButton( + markerProvider: suspend () -> MarketInstallMarker?, + onOpenMarketDetail: (MarketV2Entry) -> Unit, + modifier: Modifier = Modifier +) { + var isChecking by remember { mutableStateOf(false) } + var result by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + OutlinedButton( + onClick = { + if (isChecking) return@OutlinedButton + isChecking = true + result = null + scope.launch { + val marker = + try { + withContext(Dispatchers.IO) { markerProvider() } + } catch (e: Exception) { + null + } + result = checkMarketUpdate(marker) + isChecking = false + } + }, + modifier = modifier, + enabled = !isChecking + ) { + if (isChecking) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp + ) + } else { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.pkg_check_update)) + } + + val current = result + if (current != null) { + AlertDialog( + onDismissRequest = { result = null }, + title = { Text(stringResource(R.string.pkg_check_update)) }, + text = { + Text( + text = + when (current) { + is MarketUpdateCheckResult.UpToDate -> + stringResource(R.string.pkg_check_update_up_to_date) + is MarketUpdateCheckResult.UpdateAvailable -> + stringResource(R.string.pkg_check_update_available, current.latestVersion) + MarketUpdateCheckResult.NotFromMarket -> + stringResource(R.string.pkg_check_update_not_market) + MarketUpdateCheckResult.EntryMissing -> + stringResource(R.string.pkg_check_update_entry_missing) + MarketUpdateCheckResult.Failed -> + stringResource(R.string.pkg_check_update_failed) + } + ) + }, + confirmButton = { + if (current is MarketUpdateCheckResult.UpdateAvailable) { + TextButton( + onClick = { + result = null + onOpenMarketDetail(current.entry) + } + ) { + Text(stringResource(R.string.pkg_check_update_go)) + } + } else { + TextButton(onClick = { result = null }) { + Text(stringResource(R.string.ok)) + } + } + }, + dismissButton = { + TextButton(onClick = { result = null }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/packages/market/MarketUpdateChecker.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/packages/market/MarketUpdateChecker.kt new file mode 100644 index 0000000000..094476474a --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/packages/market/MarketUpdateChecker.kt @@ -0,0 +1,47 @@ +package com.ai.assistance.operit.ui.features.packages.market + +import com.ai.assistance.operit.data.api.MarketStatsApiService +import com.ai.assistance.operit.data.api.MarketV2Entry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** 市场更新检查的统一结果(包 / MCP / 技能三类界面共用) */ +sealed interface MarketUpdateCheckResult { + /** 本地安装无市场来源标记(例如手工导入) */ + data object NotFromMarket : MarketUpdateCheckResult + + /** 市场条目已不存在 */ + data object EntryMissing : MarketUpdateCheckResult + + /** 网络或服务端错误 */ + data object Failed : MarketUpdateCheckResult + + /** 已是最新版 */ + data class UpToDate(val entry: MarketV2Entry) : MarketUpdateCheckResult + + /** 有更新可用 */ + data class UpdateAvailable(val entry: MarketV2Entry, val latestVersion: String) : MarketUpdateCheckResult +} + +/** + * 依据安装标记执行一次市场更新检查。 + * @param marker 安装标记;为 null 时直接返回 NotFromMarket + */ +suspend fun checkMarketUpdate(marker: MarketInstallMarker?): MarketUpdateCheckResult { + if (marker == null) return MarketUpdateCheckResult.NotFromMarket + val entryResult = + withContext(Dispatchers.IO) { + MarketStatsApiService().getEntry(marker.entryId) + } + if (entryResult.isFailure) return MarketUpdateCheckResult.Failed + val entry = entryResult.getOrNull() ?: return MarketUpdateCheckResult.EntryMissing + val latest = entry.latestVersion + return if (latest == null || latest.id.isBlank() || latest.id == marker.versionId) { + MarketUpdateCheckResult.UpToDate(entry) + } else { + MarketUpdateCheckResult.UpdateAvailable( + entry = entry, + latestVersion = latest.version.ifBlank { latest.id } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/packages/screens/MCPConfigScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/packages/screens/MCPConfigScreen.kt index 7b566de286..bb020f9780 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/packages/screens/MCPConfigScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/packages/screens/MCPConfigScreen.kt @@ -54,6 +54,7 @@ import com.ai.assistance.operit.util.AppLogger import android.widget.Toast import androidx.compose.ui.res.stringResource import com.ai.assistance.operit.R +import com.ai.assistance.operit.data.api.MarketV2Entry import org.json.JSONObject import java.util.* @@ -70,6 +71,7 @@ import com.ai.assistance.operit.ui.features.startup.screens.LocalPluginLoadingSt @Composable fun MCPConfigScreen( onNavigateToMCPMarket: () -> Unit = {}, + onOpenMcpMarketDetail: (MarketV2Entry) -> Unit = {}, searchQuery: String = "" ) { val context = LocalContext.current @@ -435,6 +437,10 @@ fun MCPConfigScreen( }, installedPath = installedPath, pluginConfig = pluginConfigJson, + onOpenMarketDetail = { entry -> + selectedPluginForDetails = null + onOpenMcpMarketDetail(entry) + }, onSaveConfig = { selectedPluginId?.let { pluginId -> scope.launch { diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/packages/screens/PackageManagerScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/packages/screens/PackageManagerScreen.kt index bcacf88e39..75b01bedec 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/packages/screens/PackageManagerScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/packages/screens/PackageManagerScreen.kt @@ -913,6 +913,7 @@ fun PackageManagerScreen( skillRepository = skillRepository, snackbarHostState = snackbarHostState, onNavigateToSkillMarket = onNavigateToSkillMarket, + onOpenSkillMarketDetail = onOpenMarketDetail, searchQuery = skillSearchQuery, skillOrder = skillOrder, onSaveSkillOrder = { newOrder -> @@ -927,6 +928,7 @@ fun PackageManagerScreen( PackageTab.MCP -> { MCPConfigScreen( onNavigateToMCPMarket = onNavigateToMCPMarket, + onOpenMcpMarketDetail = onOpenMarketDetail, searchQuery = mcpSearchQuery ) } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/packages/screens/SkillConfigScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/packages/screens/SkillConfigScreen.kt index 27a1a66401..28c87560e0 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/packages/screens/SkillConfigScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/packages/screens/SkillConfigScreen.kt @@ -74,9 +74,12 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.ai.assistance.operit.R import com.ai.assistance.operit.core.tools.skill.SkillPackage +import com.ai.assistance.operit.data.api.MarketV2Entry import com.ai.assistance.operit.data.preferences.SkillVisibilityPreferences import com.ai.assistance.operit.data.skill.SkillRepository import com.ai.assistance.operit.ui.common.displays.MarkdownTextComposable +import com.ai.assistance.operit.ui.features.packages.market.MarketUpdateCheckButton +import com.ai.assistance.operit.ui.features.packages.market.readMarketInstallMarkerForSkill import java.io.File import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -90,6 +93,7 @@ fun SkillConfigScreen( skillRepository: SkillRepository, snackbarHostState: SnackbarHostState, onNavigateToSkillMarket: () -> Unit = {}, + onOpenSkillMarketDetail: (MarketV2Entry) -> Unit = {}, searchQuery: String = "", skillOrder: List = emptyList(), onSaveSkillOrder: (List) -> Unit = {}, @@ -748,6 +752,12 @@ fun SkillConfigScreen( skill = skill, detail = selectedSkillDetail, isLoading = isSkillDetailLoading, + onOpenMarketDetail = { entry -> + selectedSkill = null + selectedSkillDetail = null + isSkillDetailLoading = false + onOpenSkillMarketDetail(entry) + }, onDismiss = { selectedSkill = null selectedSkillDetail = null @@ -876,7 +886,8 @@ private fun SkillDetailDialog( detail: SkillDetailDialogData?, isLoading: Boolean, onDismiss: () -> Unit, - onDelete: () -> Unit + onDelete: () -> Unit, + onOpenMarketDetail: (MarketV2Entry) -> Unit ) { var showSkillMarkdown by remember(skill.directory.absolutePath) { mutableStateOf(false) } @@ -993,8 +1004,16 @@ private fun SkillDetailDialog( } }, confirmButton = { - TextButton(onClick = onDelete) { - Text(text = stringResource(R.string.skillmgr_delete)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) + ) { + MarketUpdateCheckButton( + markerProvider = { readMarketInstallMarkerForSkill(skill.directory) }, + onOpenMarketDetail = onOpenMarketDetail + ) + TextButton(onClick = onDelete) { + Text(text = stringResource(R.string.skillmgr_delete)) + } } }, dismissButton = {