mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-17 15:27:15 +00:00
feat(plugins): show installed state in marketplace and search installed extensions
Marketplace cards now reflect whether an extension is already installed in the current workspace, and the installed-extension list gains a search box. Backend (stream install progress): - _read_httpx_response_limited gains an optional task_context: it publishes download_total from Content-Length before the first chunk and updates download_current / download_speed per chunk. The marketplace download path previously had no progress reporting; it now matches the GitHub path. - _marketplace_get forwards task_context to that helper. - install_plugin resets the per-install counters so re-installing the same plugin cannot inherit stale metadata, and reports human-readable stages: preparing -> downloading -> inspecting -> storing -> installing dependencies -> launching -> waiting for plugin to become ready. Frontend (installed state): - New marketplace-installed helper normalises the sidebar identities (plugin: author/name, mcp: author__name, skill: bare name) into one type:author/name index and resolves a card's installed state from it. useMarketplaceInstalledIndex memoises on the sidebar lists, so a finished install (which refreshes the sidebar) re-evaluates the cards automatically. - PluginMarketCardVO carries installed / hasUpdate. An installed extension turns its download affordance into a hollow green ring with a green check in place, instead of adding a separate badge; the count slot switches to the installed label. Cards with an available update use amber. - PluginMarketComponent derives the annotated list and shares the index with RecommendationLists. Frontend (install task UI): - mapActionToStage matches the new connector stage strings. The pre-download stages are checked before the generic "install" match, because "preparing plugin install" also contains "install". - Stage progress ranges are non-overlapping; overall progress interpolates on real byte counts while downloading and drifts monotonically elsewhere, capped at 99%. - The progress dialog and task queue expose the launching stage. Frontend (installed list search): - The installed list had no search at all. A query box in the page header filters by label / name / author / description, case-insensitively, applied before grouping so grouped and flat views both honour it. - Search misses and an empty list now show distinct empty states, with a clear action on a search miss. - AsyncTask entity gains the optional created_at field. i18n: new marketplace / install / search strings across all 8 locales. Verified: ruff format + check, tsc --noEmit, prettier --check, eslint (0 errors), and 89/89 frontend unit tests.
This commit is contained in:
@@ -87,8 +87,10 @@ async def _read_httpx_response_limited(
|
|||||||
response: httpx.Response,
|
response: httpx.Response,
|
||||||
*,
|
*,
|
||||||
max_bytes: int,
|
max_bytes: int,
|
||||||
|
task_context: taskmgr.TaskContext | None = None,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
content_length = response.headers.get('content-length')
|
content_length = response.headers.get('content-length')
|
||||||
|
declared_size: int | None = None
|
||||||
if content_length is not None:
|
if content_length is not None:
|
||||||
try:
|
try:
|
||||||
declared_size = int(content_length)
|
declared_size = int(content_length)
|
||||||
@@ -97,11 +99,25 @@ async def _read_httpx_response_limited(
|
|||||||
if declared_size is not None and declared_size > max_bytes:
|
if declared_size is not None and declared_size > max_bytes:
|
||||||
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
|
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
|
||||||
|
|
||||||
|
if task_context is not None and declared_size is not None:
|
||||||
|
# Publish the advertised size up-front so the UI can render a
|
||||||
|
# determinate bar even before the first chunk arrives.
|
||||||
|
task_context.metadata['download_total'] = declared_size
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
body = bytearray()
|
body = bytearray()
|
||||||
async for chunk in response.aiter_bytes(chunk_size=64 * 1024):
|
async for chunk in response.aiter_bytes(chunk_size=64 * 1024):
|
||||||
body.extend(chunk)
|
body.extend(chunk)
|
||||||
if len(body) > max_bytes:
|
if len(body) > max_bytes:
|
||||||
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
|
raise ValueError(f'Remote response exceeds the {max_bytes}-byte limit')
|
||||||
|
if task_context is not None:
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
task_context.metadata.update(
|
||||||
|
{
|
||||||
|
'download_current': len(body),
|
||||||
|
'download_speed': len(body) / elapsed if elapsed > 0 else 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
return bytes(body)
|
return bytes(body)
|
||||||
|
|
||||||
|
|
||||||
@@ -111,6 +127,7 @@ async def _marketplace_get(
|
|||||||
*,
|
*,
|
||||||
max_bytes: int,
|
max_bytes: int,
|
||||||
allow_not_found: bool = False,
|
allow_not_found: bool = False,
|
||||||
|
task_context: taskmgr.TaskContext | None = None,
|
||||||
) -> tuple[int, bytes]:
|
) -> tuple[int, bytes]:
|
||||||
async with client.stream('GET', url) as response:
|
async with client.stream('GET', url) as response:
|
||||||
if allow_not_found and response.status_code == 404:
|
if allow_not_found and response.status_code == 404:
|
||||||
@@ -119,6 +136,7 @@ async def _marketplace_get(
|
|||||||
return response.status_code, await _read_httpx_response_limited(
|
return response.status_code, await _read_httpx_response_limited(
|
||||||
response,
|
response,
|
||||||
max_bytes=max_bytes,
|
max_bytes=max_bytes,
|
||||||
|
task_context=task_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1680,6 +1698,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
client,
|
client,
|
||||||
f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{latest_version}',
|
f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{latest_version}',
|
||||||
max_bytes=_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES,
|
max_bytes=_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES,
|
||||||
|
task_context=task_context,
|
||||||
)
|
)
|
||||||
return plugin_package, latest_version
|
return plugin_package, latest_version
|
||||||
|
|
||||||
@@ -1695,7 +1714,21 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
plugin_name = str(install_info.get('plugin_name') or '')
|
plugin_name = str(install_info.get('plugin_name') or '')
|
||||||
file_bytes: bytes | None
|
file_bytes: bytes | None
|
||||||
|
|
||||||
|
if task_context is not None:
|
||||||
|
# Reset the per-install counters so re-installing the same plugin
|
||||||
|
# cannot inherit stale progress metadata from a previous task.
|
||||||
|
task_context.set_current_action('preparing plugin install')
|
||||||
|
task_context.metadata.update(
|
||||||
|
{
|
||||||
|
'download_total': 0,
|
||||||
|
'download_current': 0,
|
||||||
|
'download_speed': 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if install_source == PluginInstallSource.MARKETPLACE:
|
if install_source == PluginInstallSource.MARKETPLACE:
|
||||||
|
if task_context is not None:
|
||||||
|
task_context.set_current_action('downloading plugin package')
|
||||||
file_bytes, version = await self._download_marketplace_package(
|
file_bytes, version = await self._download_marketplace_package(
|
||||||
execution_context,
|
execution_context,
|
||||||
plugin_author,
|
plugin_author,
|
||||||
@@ -1719,6 +1752,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
else:
|
else:
|
||||||
raise ValueError(f'Unsupported plugin install source: {install_source.value}')
|
raise ValueError(f'Unsupported plugin install source: {install_source.value}')
|
||||||
|
|
||||||
|
if task_context is not None:
|
||||||
|
task_context.set_current_action('inspecting plugin package')
|
||||||
manifest_author, manifest_name = self._inspect_plugin_package(file_bytes, task_context)
|
manifest_author, manifest_name = self._inspect_plugin_package(file_bytes, task_context)
|
||||||
if not manifest_author or not manifest_name:
|
if not manifest_author or not manifest_name:
|
||||||
raise ValueError('Plugin package manifest identity is missing')
|
raise ValueError('Plugin package manifest identity is missing')
|
||||||
@@ -1730,8 +1765,12 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
if task_context is not None:
|
if task_context is not None:
|
||||||
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||||
|
|
||||||
|
if task_context is not None:
|
||||||
|
task_context.set_current_action('storing plugin package')
|
||||||
artifact_digest = hashlib.sha256(file_bytes).hexdigest()
|
artifact_digest = hashlib.sha256(file_bytes).hexdigest()
|
||||||
await self._store_artifact_package(execution_context, artifact_digest, file_bytes)
|
await self._store_artifact_package(execution_context, artifact_digest, file_bytes)
|
||||||
|
if task_context is not None:
|
||||||
|
task_context.set_current_action('installing plugin dependencies')
|
||||||
try:
|
try:
|
||||||
binding, previous_digest, previous_was_durable = await self._persist_installation_package(
|
binding, previous_digest, previous_was_durable = await self._persist_installation_package(
|
||||||
execution_context,
|
execution_context,
|
||||||
@@ -1749,6 +1788,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
plugin_author=plugin_author,
|
plugin_author=plugin_author,
|
||||||
plugin_name=plugin_name,
|
plugin_name=plugin_name,
|
||||||
)
|
)
|
||||||
|
if task_context is not None:
|
||||||
|
task_context.set_current_action('launching plugin')
|
||||||
await self._apply_desired_state(
|
await self._apply_desired_state(
|
||||||
PluginInstallationDesiredState(binding=binding, enabled=True),
|
PluginInstallationDesiredState(binding=binding, enabled=True),
|
||||||
artifact_package=file_bytes,
|
artifact_package=file_bytes,
|
||||||
@@ -1766,6 +1807,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
pass
|
pass
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.ap.logger.debug(f'Legacy OSS plugin cleanup skipped: {exc}')
|
self.ap.logger.debug(f'Legacy OSS plugin cleanup skipped: {exc}')
|
||||||
|
if task_context is not None:
|
||||||
|
task_context.set_current_action('waiting for plugin to become ready')
|
||||||
await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context)
|
await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context)
|
||||||
|
|
||||||
async def upgrade_plugin(
|
async def upgrade_plugin(
|
||||||
|
|||||||
+6
@@ -12,6 +12,7 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
Server,
|
Server,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Rocket,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -39,6 +40,11 @@ const STAGES: {
|
|||||||
icon: Package,
|
icon: Package,
|
||||||
i18nKey: 'plugins.installProgress.installingDeps',
|
i18nKey: 'plugins.installProgress.installingDeps',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: InstallStage.LAUNCHING,
|
||||||
|
icon: Rocket,
|
||||||
|
i18nKey: 'plugins.installProgress.launching',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function getStageIndex(stage: InstallStage): number {
|
function getStageIndex(stage: InstallStage): number {
|
||||||
|
|||||||
+93
-34
@@ -84,42 +84,84 @@ export function usePluginInstallTasks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map backend `current_action` to our InstallStage.
|
* Map the backend `current_action` string to an InstallStage.
|
||||||
|
*
|
||||||
|
* The runtime connector emits human-readable stage strings; each branch here
|
||||||
|
* matches the wording produced by the connector so newly added stages show up
|
||||||
|
* in the UI without a protocol change.
|
||||||
*/
|
*/
|
||||||
function mapActionToStage(action: string): InstallStage {
|
function mapActionToStage(action: string): InstallStage {
|
||||||
if (!action) return InstallStage.DOWNLOADING;
|
const lower = (action || '').toLowerCase();
|
||||||
const lower = action.toLowerCase();
|
|
||||||
if (lower.includes('download')) return InstallStage.DOWNLOADING;
|
if (
|
||||||
if (lower.includes('dependencies') || lower.includes('requirements'))
|
lower.includes('installed') ||
|
||||||
return InstallStage.INSTALLING_DEPS;
|
lower.includes('complete') ||
|
||||||
if (lower.includes('initializ') || lower.includes('setting'))
|
lower.includes('ready')
|
||||||
return InstallStage.INSTALLING_DEPS;
|
) {
|
||||||
if (lower.includes('launch')) return InstallStage.INSTALLING_DEPS;
|
// "waiting for plugin to become ready" is still an active stage.
|
||||||
if (lower.includes('installed') || lower.includes('complete'))
|
if (lower.includes('waiting')) return InstallStage.LAUNCHING;
|
||||||
return InstallStage.DONE;
|
return InstallStage.DONE;
|
||||||
|
}
|
||||||
|
if (lower.includes('launch') || lower.includes('start')) {
|
||||||
|
return InstallStage.LAUNCHING;
|
||||||
|
}
|
||||||
|
// Check the pre-download stages before the generic "install" match below,
|
||||||
|
// because "preparing plugin install" also contains "install".
|
||||||
|
if (lower.includes('prepar')) return InstallStage.DOWNLOADING;
|
||||||
|
if (lower.includes('download')) return InstallStage.DOWNLOADING;
|
||||||
|
|
||||||
|
if (
|
||||||
|
lower.includes('dependenc') ||
|
||||||
|
lower.includes('requirements') ||
|
||||||
|
lower.includes('install')
|
||||||
|
) {
|
||||||
|
return InstallStage.INSTALLING_DEPS;
|
||||||
|
}
|
||||||
|
if (lower.includes('initializ') || lower.includes('configur')) {
|
||||||
|
return InstallStage.INITIALIZING;
|
||||||
|
}
|
||||||
|
if (lower.includes('inspect') || lower.includes('storing')) {
|
||||||
|
return InstallStage.INSTALLING_DEPS;
|
||||||
|
}
|
||||||
|
|
||||||
return InstallStage.DOWNLOADING;
|
return InstallStage.DOWNLOADING;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get overall progress percentage from a stage.
|
* Progress range (start → end) attributed to each stage, used to build a
|
||||||
|
* smooth determinate bar that never goes backwards.
|
||||||
*/
|
*/
|
||||||
function stageToProgress(stage: InstallStage): number {
|
const STAGE_PROGRESS_RANGE: Record<InstallStage, [number, number]> = {
|
||||||
switch (stage) {
|
[InstallStage.DOWNLOADING]: [5, 45],
|
||||||
case InstallStage.DOWNLOADING:
|
[InstallStage.INSTALLING_DEPS]: [45, 85],
|
||||||
return 10;
|
[InstallStage.INITIALIZING]: [85, 88],
|
||||||
case InstallStage.INSTALLING_DEPS:
|
[InstallStage.LAUNCHING]: [88, 97],
|
||||||
return 70;
|
[InstallStage.DONE]: [100, 100],
|
||||||
case InstallStage.INITIALIZING:
|
[InstallStage.ERROR]: [0, 0],
|
||||||
return 70;
|
};
|
||||||
case InstallStage.LAUNCHING:
|
|
||||||
return 85;
|
/**
|
||||||
case InstallStage.DONE:
|
* Compute overall progress, preferring real byte counts over the stage range
|
||||||
return 100;
|
* when the backend has reported a download size.
|
||||||
case InstallStage.ERROR:
|
*/
|
||||||
return 0;
|
function computeOverallProgress(task: {
|
||||||
default:
|
stage: InstallStage;
|
||||||
return 0;
|
downloadCurrent?: number;
|
||||||
|
downloadTotal?: number;
|
||||||
|
}): number {
|
||||||
|
const [start, end] = STAGE_PROGRESS_RANGE[task.stage] ?? [0, 0];
|
||||||
|
|
||||||
|
if (
|
||||||
|
task.stage === InstallStage.DOWNLOADING &&
|
||||||
|
task.downloadTotal &&
|
||||||
|
task.downloadTotal > 0 &&
|
||||||
|
task.downloadCurrent != null
|
||||||
|
) {
|
||||||
|
const ratio = Math.min(1, task.downloadCurrent / task.downloadTotal);
|
||||||
|
return Math.round(start + (end - start) * ratio);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return start;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -172,7 +214,14 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
stage = mapActionToStage(action);
|
stage = mapActionToStage(action);
|
||||||
overallProgress = Math.min(95, stageToProgress(stage));
|
overallProgress = Math.min(
|
||||||
|
99,
|
||||||
|
computeOverallProgress({
|
||||||
|
stage,
|
||||||
|
downloadCurrent: num(md.download_current),
|
||||||
|
downloadTotal: num(md.download_total),
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pluginName = str(md.plugin_name) || task.label || `${source} extension`;
|
const pluginName = str(md.plugin_name) || task.label || `${source} extension`;
|
||||||
@@ -333,16 +382,26 @@ export function PluginInstallTaskProvider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stage = mapActionToStage(action);
|
const stage = mapActionToStage(action);
|
||||||
const baseProgress = stageToProgress(stage);
|
const [rangeStart, rangeEnd] = STAGE_PROGRESS_RANGE[stage] ?? [
|
||||||
// Add small time-based increment within stage
|
0, 0,
|
||||||
|
];
|
||||||
|
// Prefer real byte counts where available; otherwise drift
|
||||||
|
// slowly inside the current stage so the bar still moves.
|
||||||
const elapsed = (Date.now() - t.startedAt) / 1000;
|
const elapsed = (Date.now() - t.startedAt) / 1000;
|
||||||
const withinStageIncrement = Math.min(
|
const drift = Math.min(
|
||||||
15,
|
Math.max(0, rangeEnd - rangeStart - 1),
|
||||||
Math.floor(elapsed / 2),
|
Math.floor(elapsed / 2),
|
||||||
);
|
);
|
||||||
const progress = Math.min(
|
const progress = Math.min(
|
||||||
95,
|
99,
|
||||||
baseProgress + withinStageIncrement,
|
Math.max(
|
||||||
|
t.overallProgress,
|
||||||
|
computeOverallProgress({
|
||||||
|
stage,
|
||||||
|
downloadCurrent,
|
||||||
|
downloadTotal,
|
||||||
|
}) + drift,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Progress } from '@/components/ui/progress';
|
|||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
Package,
|
Package,
|
||||||
|
Rocket,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -30,6 +31,7 @@ import { cn } from '@/lib/utils';
|
|||||||
const STAGE_ICONS: Record<string, React.ElementType> = {
|
const STAGE_ICONS: Record<string, React.ElementType> = {
|
||||||
[InstallStage.DOWNLOADING]: Download,
|
[InstallStage.DOWNLOADING]: Download,
|
||||||
[InstallStage.INSTALLING_DEPS]: Package,
|
[InstallStage.INSTALLING_DEPS]: Package,
|
||||||
|
[InstallStage.LAUNCHING]: Rocket,
|
||||||
[InstallStage.DONE]: CheckCircle2,
|
[InstallStage.DONE]: CheckCircle2,
|
||||||
[InstallStage.ERROR]: XCircle,
|
[InstallStage.ERROR]: XCircle,
|
||||||
};
|
};
|
||||||
@@ -95,6 +97,8 @@ function TaskQueueItem({
|
|||||||
return t('plugins.installProgress.downloading');
|
return t('plugins.installProgress.downloading');
|
||||||
case InstallStage.INSTALLING_DEPS:
|
case InstallStage.INSTALLING_DEPS:
|
||||||
return t('plugins.installProgress.installingDeps');
|
return t('plugins.installProgress.installingDeps');
|
||||||
|
case InstallStage.LAUNCHING:
|
||||||
|
return t('plugins.installProgress.launching');
|
||||||
case InstallStage.DONE:
|
case InstallStage.DONE:
|
||||||
return isDone
|
return isDone
|
||||||
? getInstallCompleteMessage()
|
? getInstallCompleteMessage()
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import { extractI18nObject } from '@/i18n/I18nProvider';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
|
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
|
||||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||||
import { Loader2, Puzzle, Server, Sparkles } from 'lucide-react';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Loader2, Puzzle, Search, Server, Sparkles, X } from 'lucide-react';
|
||||||
|
|
||||||
export interface PluginInstalledComponentRef {
|
export interface PluginInstalledComponentRef {
|
||||||
refreshPluginList: () => void;
|
refreshPluginList: () => void;
|
||||||
@@ -60,12 +61,16 @@ export const FilterOptions = [
|
|||||||
interface PluginInstalledComponentProps {
|
interface PluginInstalledComponentProps {
|
||||||
filterType: FilterType;
|
filterType: FilterType;
|
||||||
groupByType: boolean;
|
groupByType: boolean;
|
||||||
|
/** Free-text filter over label / name / author / description. */
|
||||||
|
searchQuery?: string;
|
||||||
|
/** Invoked when the user clears the search from the empty state. */
|
||||||
|
onClearSearch?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PluginInstalledComponent = forwardRef<
|
const PluginInstalledComponent = forwardRef<
|
||||||
PluginInstalledComponentRef,
|
PluginInstalledComponentRef,
|
||||||
PluginInstalledComponentProps
|
PluginInstalledComponentProps
|
||||||
>(({ filterType, groupByType }, ref) => {
|
>(({ filterType, groupByType, searchQuery = '', onClearSearch }, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
|
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
|
||||||
@@ -307,11 +312,21 @@ const PluginInstalledComponent = forwardRef<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Match the query against the fields a user can actually see on the card
|
||||||
|
// (label / name / author) plus the description, case-insensitively.
|
||||||
|
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||||
const filteredExtensions = extensionList.filter((ext) => {
|
const filteredExtensions = extensionList.filter((ext) => {
|
||||||
if (filterType === 'all') return true;
|
if (filterType !== 'all' && ext.type !== filterType) return false;
|
||||||
return ext.type === filterType;
|
if (!normalizedQuery) return true;
|
||||||
|
return [ext.label, ext.name, ext.author, ext.description].some((field) =>
|
||||||
|
(field || '').toLowerCase().includes(normalizedQuery),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
onClearSearch?.();
|
||||||
|
};
|
||||||
|
|
||||||
const showGrouped = groupByType && filterType === 'all';
|
const showGrouped = groupByType && filterType === 'all';
|
||||||
const groupOrder: ExtensionType[] = ['plugin', 'mcp', 'skill'];
|
const groupOrder: ExtensionType[] = ['plugin', 'mcp', 'skill'];
|
||||||
const groupedExtensions = groupOrder
|
const groupedExtensions = groupOrder
|
||||||
@@ -461,10 +476,26 @@ const PluginInstalledComponent = forwardRef<
|
|||||||
</div>
|
</div>
|
||||||
) : filteredExtensions.length === 0 ? (
|
) : filteredExtensions.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center text-muted-foreground min-h-[60vh] w-full gap-2">
|
<div className="flex flex-col items-center justify-center text-muted-foreground min-h-[60vh] w-full gap-2">
|
||||||
<Puzzle className="h-[3rem] w-[3rem]" />
|
{normalizedQuery ? (
|
||||||
<div className="text-lg mb-2">
|
<>
|
||||||
{t('plugins.noExtensionInstalled')}
|
<Search className="h-[3rem] w-[3rem]" />
|
||||||
</div>
|
<div className="text-lg mb-2">
|
||||||
|
{t('plugins.noMatchingExtensions', {
|
||||||
|
query: searchQuery.trim(),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={clearSearch}>
|
||||||
|
{t('common.clear')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Puzzle className="h-[3rem] w-[3rem]" />
|
||||||
|
<div className="text-lg mb-2">
|
||||||
|
{t('plugins.noExtensionInstalled')}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : showGrouped ? (
|
) : showGrouped ? (
|
||||||
<div className="flex flex-col gap-4 pb-4">
|
<div className="flex flex-col gap-4 pb-4">
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
|
import {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
Suspense,
|
||||||
|
} from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +44,10 @@ import {
|
|||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
|
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
|
||||||
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
|
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
|
||||||
|
import {
|
||||||
|
resolveInstalledState,
|
||||||
|
useMarketplaceInstalledIndex,
|
||||||
|
} from './marketplace-installed';
|
||||||
import { RecommendationLists } from './RecommendationLists';
|
import { RecommendationLists } from './RecommendationLists';
|
||||||
import type { RecommendationList } from './RecommendationLists';
|
import type { RecommendationList } from './RecommendationLists';
|
||||||
import {
|
import {
|
||||||
@@ -122,6 +133,8 @@ function MarketPageContent({
|
|||||||
const [recommendationLists, setRecommendationLists] = useState<
|
const [recommendationLists, setRecommendationLists] = useState<
|
||||||
RecommendationList[]
|
RecommendationList[]
|
||||||
>([]);
|
>([]);
|
||||||
|
// Installed extensions from the sidebar; used to mark market cards.
|
||||||
|
const installedIndex = useMarketplaceInstalledIndex();
|
||||||
const [plugins, setPlugins] = useState<PluginMarketCardVO[]>([]);
|
const [plugins, setPlugins] = useState<PluginMarketCardVO[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
@@ -571,7 +584,27 @@ function MarketPageContent({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const visiblePlugins = plugins;
|
// Annotate cards with installed state derived from the sidebar index. This is
|
||||||
|
// computed (rather than baked into `plugins`) so a finished install updates
|
||||||
|
// the badges as soon as the sidebar refreshes.
|
||||||
|
const visiblePlugins = useMemo(
|
||||||
|
() =>
|
||||||
|
plugins.map((plugin) => {
|
||||||
|
const state = resolveInstalledState(installedIndex, plugin);
|
||||||
|
if (
|
||||||
|
state.installed === plugin.installed &&
|
||||||
|
state.hasUpdate === plugin.hasUpdate
|
||||||
|
) {
|
||||||
|
return plugin;
|
||||||
|
}
|
||||||
|
return new PluginMarketCardVO({
|
||||||
|
...plugin,
|
||||||
|
installed: state.installed,
|
||||||
|
hasUpdate: state.hasUpdate,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
[plugins, installedIndex],
|
||||||
|
);
|
||||||
|
|
||||||
// 加载更多
|
// 加载更多
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
@@ -853,6 +886,7 @@ function MarketPageContent({
|
|||||||
onInstall={handleInstallPlugin}
|
onInstall={handleInstallPlugin}
|
||||||
installDisabled={installDisabled}
|
installDisabled={installDisabled}
|
||||||
installDisabledTooltip={installDisabledTooltip}
|
installDisabledTooltip={installDisabledTooltip}
|
||||||
|
installedIndex={installedIndex}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { I18nObject } from '@/app/infra/entities/common';
|
|||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
InstalledExtensionEntry,
|
||||||
|
resolveInstalledState,
|
||||||
|
} from './marketplace-installed';
|
||||||
|
|
||||||
export interface RecommendationList {
|
export interface RecommendationList {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
@@ -21,6 +25,7 @@ export interface RecommendationList {
|
|||||||
function pluginToVO(
|
function pluginToVO(
|
||||||
plugin: PluginV4,
|
plugin: PluginV4,
|
||||||
t: (key: string) => string,
|
t: (key: string) => string,
|
||||||
|
installedIndex?: Map<string, InstalledExtensionEntry>,
|
||||||
): PluginMarketCardVO {
|
): PluginMarketCardVO {
|
||||||
const cloudClient = getCloudServiceClientSync();
|
const cloudClient = getCloudServiceClientSync();
|
||||||
// Recommendation lists are mixed-type; resolve the icon per extension type,
|
// Recommendation lists are mixed-type; resolve the icon per extension type,
|
||||||
@@ -32,6 +37,14 @@ function pluginToVO(
|
|||||||
plugin.icon,
|
plugin.icon,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const installedState = installedIndex
|
||||||
|
? resolveInstalledState(installedIndex, {
|
||||||
|
type: plugin.type,
|
||||||
|
author: plugin.author,
|
||||||
|
pluginName: plugin.name,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return new PluginMarketCardVO({
|
return new PluginMarketCardVO({
|
||||||
pluginId: plugin.author + ' / ' + plugin.name,
|
pluginId: plugin.author + ' / ' + plugin.name,
|
||||||
author: plugin.author,
|
author: plugin.author,
|
||||||
@@ -47,6 +60,8 @@ function pluginToVO(
|
|||||||
components: plugin.components,
|
components: plugin.components,
|
||||||
tags: plugin.tags || [],
|
tags: plugin.tags || [],
|
||||||
type: plugin.type,
|
type: plugin.type,
|
||||||
|
installed: installedState?.installed,
|
||||||
|
hasUpdate: installedState?.hasUpdate,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +72,7 @@ function RecommendationListRow({
|
|||||||
installDisabled,
|
installDisabled,
|
||||||
installDisabledTooltip,
|
installDisabledTooltip,
|
||||||
isLast,
|
isLast,
|
||||||
|
installedIndex,
|
||||||
}: {
|
}: {
|
||||||
list: RecommendationList;
|
list: RecommendationList;
|
||||||
tagNames: Record<string, string>;
|
tagNames: Record<string, string>;
|
||||||
@@ -64,6 +80,7 @@ function RecommendationListRow({
|
|||||||
installDisabled?: boolean;
|
installDisabled?: boolean;
|
||||||
installDisabledTooltip?: string;
|
installDisabledTooltip?: string;
|
||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
|
installedIndex?: Map<string, InstalledExtensionEntry>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
@@ -264,7 +281,7 @@ function RecommendationListRow({
|
|||||||
{visiblePlugins.map((plugin) => (
|
{visiblePlugins.map((plugin) => (
|
||||||
<PluginMarketCardComponent
|
<PluginMarketCardComponent
|
||||||
key={plugin.author + ' / ' + plugin.name}
|
key={plugin.author + ' / ' + plugin.name}
|
||||||
cardVO={pluginToVO(plugin, t)}
|
cardVO={pluginToVO(plugin, t, installedIndex)}
|
||||||
tagNames={tagNames}
|
tagNames={tagNames}
|
||||||
onInstall={onInstall}
|
onInstall={onInstall}
|
||||||
installDisabled={installDisabled}
|
installDisabled={installDisabled}
|
||||||
@@ -285,12 +302,14 @@ export function RecommendationLists({
|
|||||||
onInstall,
|
onInstall,
|
||||||
installDisabled,
|
installDisabled,
|
||||||
installDisabledTooltip,
|
installDisabledTooltip,
|
||||||
|
installedIndex,
|
||||||
}: {
|
}: {
|
||||||
lists: RecommendationList[];
|
lists: RecommendationList[];
|
||||||
tagNames: Record<string, string>;
|
tagNames: Record<string, string>;
|
||||||
onInstall: (cardVO: PluginMarketCardVO) => void;
|
onInstall: (cardVO: PluginMarketCardVO) => void;
|
||||||
installDisabled?: boolean;
|
installDisabled?: boolean;
|
||||||
installDisabledTooltip?: string;
|
installDisabledTooltip?: string;
|
||||||
|
installedIndex?: Map<string, InstalledExtensionEntry>;
|
||||||
}) {
|
}) {
|
||||||
if (!lists || lists.length === 0) return null;
|
if (!lists || lists.length === 0) return null;
|
||||||
|
|
||||||
@@ -305,6 +324,7 @@ export function RecommendationLists({
|
|||||||
installDisabled={installDisabled}
|
installDisabled={installDisabled}
|
||||||
installDisabledTooltip={installDisabledTooltip}
|
installDisabledTooltip={installDisabledTooltip}
|
||||||
isLast={index === lists.length - 1}
|
isLast={index === lists.length - 1}
|
||||||
|
installedIndex={installedIndex}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div className="border-b border-border mb-6" />
|
<div className="border-b border-border mb-6" />
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marketplace extensions are addressed as `author/name`, while installed
|
||||||
|
* extensions in the sidebar use slightly different identities per kind:
|
||||||
|
* - plugins: `author/name`
|
||||||
|
* - MCP servers: `author__name` (double underscore)
|
||||||
|
* - skills: the bare skill name
|
||||||
|
*
|
||||||
|
* The index below normalises all of them to a single `type:author/name` shape
|
||||||
|
* so a marketplace card can be matched with one lookup.
|
||||||
|
*/
|
||||||
|
export interface InstalledExtensionEntry {
|
||||||
|
/** An installed extension of the same identity has a newer remote version. */
|
||||||
|
hasUpdate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarketplaceInstalledState {
|
||||||
|
installed: boolean;
|
||||||
|
hasUpdate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Identity for a marketplace extension card. */
|
||||||
|
export function installedExtensionKey(
|
||||||
|
type: string | undefined,
|
||||||
|
author: string,
|
||||||
|
name: string,
|
||||||
|
): string {
|
||||||
|
return `${type || 'plugin'}:${author}/${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the installed-extension lookup from the sidebar entity lists.
|
||||||
|
*
|
||||||
|
* Skills are indexed under both the bare name and the `author/name` form so a
|
||||||
|
* marketplace skill card resolves regardless of how it was published.
|
||||||
|
*/
|
||||||
|
export function buildInstalledIndex(
|
||||||
|
plugins: { id: string; hasUpdate?: boolean }[],
|
||||||
|
mcpServers: { id: string }[],
|
||||||
|
skills: { id: string }[],
|
||||||
|
): Map<string, InstalledExtensionEntry> {
|
||||||
|
const index = new Map<string, InstalledExtensionEntry>();
|
||||||
|
|
||||||
|
for (const plugin of plugins) {
|
||||||
|
index.set(installedExtensionKey('plugin', ...splitIdentity(plugin.id)), {
|
||||||
|
hasUpdate: plugin.hasUpdate ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const server of mcpServers) {
|
||||||
|
// MCP servers are keyed with `__`; normalise to `author/name`.
|
||||||
|
index.set(
|
||||||
|
installedExtensionKey(
|
||||||
|
'mcp',
|
||||||
|
...splitIdentity(server.id.replace(/__/g, '/')),
|
||||||
|
),
|
||||||
|
{ hasUpdate: false },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const skill of skills) {
|
||||||
|
const entry: InstalledExtensionEntry = { hasUpdate: false };
|
||||||
|
const identity = splitIdentity(skill.id);
|
||||||
|
index.set(installedExtensionKey('skill', ...identity), entry);
|
||||||
|
// Skills are stored under their bare name but marketplace cards always
|
||||||
|
// carry `author/name`, so also index the name-only form.
|
||||||
|
index.set(`skill:${skill.id}`, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split an `author/name` identity, tolerating a missing author. */
|
||||||
|
function splitIdentity(identity: string): [string, string] {
|
||||||
|
const slash = identity.indexOf('/');
|
||||||
|
if (slash < 0) return ['', identity];
|
||||||
|
return [identity.slice(0, slash), identity.slice(slash + 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve whether a marketplace extension is already installed.
|
||||||
|
*
|
||||||
|
* Unknown types fall back to `plugin`, matching the marketplace defaults.
|
||||||
|
*/
|
||||||
|
export function resolveInstalledState(
|
||||||
|
index: Map<string, InstalledExtensionEntry>,
|
||||||
|
extension: { type?: string; author: string; pluginName: string },
|
||||||
|
): MarketplaceInstalledState {
|
||||||
|
const type = extension.type || 'plugin';
|
||||||
|
const candidates = [
|
||||||
|
`${type}:${extension.author}/${extension.pluginName}`,
|
||||||
|
// Skills may be indexed under their bare name.
|
||||||
|
`${type}:${extension.pluginName}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const key of candidates) {
|
||||||
|
const entry = index.get(key);
|
||||||
|
if (entry) {
|
||||||
|
return { installed: true, hasUpdate: entry.hasUpdate };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { installed: false, hasUpdate: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive installed-extension index derived from the sidebar data context.
|
||||||
|
*
|
||||||
|
* Because the index is memoised on the sidebar lists, a finished install (which
|
||||||
|
* triggers a sidebar refresh) automatically re-evaluates the marketplace cards.
|
||||||
|
*/
|
||||||
|
export function useMarketplaceInstalledIndex(): Map<
|
||||||
|
string,
|
||||||
|
InstalledExtensionEntry
|
||||||
|
> {
|
||||||
|
const { plugins, mcpServers, skills } = useSidebarData();
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => buildInstalledIndex(plugins, mcpServers, skills),
|
||||||
|
[plugins, mcpServers, skills],
|
||||||
|
);
|
||||||
|
}
|
||||||
+79
-25
@@ -3,7 +3,14 @@ import { useRef, useState, useEffect } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import PluginComponentList from '../PluginComponentList';
|
import PluginComponentList from '../PluginComponentList';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Info, Package, ExternalLink, Heart, Loader2 } from 'lucide-react';
|
import {
|
||||||
|
Info,
|
||||||
|
Package,
|
||||||
|
ExternalLink,
|
||||||
|
Heart,
|
||||||
|
Loader2,
|
||||||
|
Check,
|
||||||
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -48,6 +55,9 @@ export default function PluginMarketCardComponent({
|
|||||||
return keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever');
|
return keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever');
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const isInstalled = !!cardVO.installed;
|
||||||
|
const hasUpdate = !!cardVO.hasUpdate;
|
||||||
|
|
||||||
const showTypeBadge = cardVO.type;
|
const showTypeBadge = cardVO.type;
|
||||||
const typeLabel =
|
const typeLabel =
|
||||||
cardVO.type === 'mcp'
|
cardVO.type === 'mcp'
|
||||||
@@ -158,12 +168,33 @@ export default function PluginMarketCardComponent({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// An already-installed extension turns its download affordance into a filled
|
||||||
|
// green circle-check, so the card reads as "installed" in place instead of
|
||||||
|
// offering another install.
|
||||||
|
const showInstalledMark = isInstalled && !hasUpdate;
|
||||||
|
|
||||||
|
// Bottom-right slot: the component list.
|
||||||
|
const bottomTrailing =
|
||||||
|
cardVO.components && Object.keys(cardVO.components).length > 0 ? (
|
||||||
|
<PluginComponentList
|
||||||
|
components={cardVO.components}
|
||||||
|
showComponentName={false}
|
||||||
|
showTitle={false}
|
||||||
|
useBadge={true}
|
||||||
|
t={t}
|
||||||
|
responsive={false}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
const cardContent = (
|
const cardContent = (
|
||||||
<div
|
<div
|
||||||
role={installDisabled ? 'group' : 'button'}
|
role={installDisabled ? 'group' : 'button'}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-disabled={installDisabled}
|
aria-disabled={installDisabled}
|
||||||
aria-label={t('market.installCard', { name: cardVO.label })}
|
aria-label={
|
||||||
|
isInstalled
|
||||||
|
? t('market.installedCard', { name: cardVO.label })
|
||||||
|
: t('market.installCard', { name: cardVO.label })
|
||||||
|
}
|
||||||
className={`w-[100%] h-[10rem] bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] transition-shadow duration-200 outline-none dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] relative ${
|
className={`w-[100%] h-[10rem] bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] transition-shadow duration-200 outline-none dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] relative ${
|
||||||
installDisabled
|
installDisabled
|
||||||
? 'cursor-not-allowed opacity-60'
|
? 'cursor-not-allowed opacity-60'
|
||||||
@@ -321,20 +352,50 @@ export default function PluginMarketCardComponent({
|
|||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-start gap-2 min-w-0 overflow-hidden">
|
<div className="flex flex-row items-center justify-start gap-2 min-w-0 overflow-hidden">
|
||||||
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
|
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
|
||||||
<svg
|
{showInstalledMark ? (
|
||||||
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
|
// Hollow green ring enclosing a green check: the download
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
// affordance becomes an "installed" mark in place.
|
||||||
viewBox="0 0 24 24"
|
<span
|
||||||
fill="none"
|
title={t('market.installed')}
|
||||||
stroke="currentColor"
|
aria-label={t('market.installed')}
|
||||||
strokeWidth="2"
|
className="flex h-4 w-4 sm:h-[1.2rem] sm:w-[1.2rem] flex-shrink-0 items-center justify-center rounded-full border-2 border-green-500 dark:border-green-400"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className="h-2 w-2 text-green-500 dark:text-green-400 sm:h-2.5 sm:w-2.5"
|
||||||
|
strokeWidth={3.5}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] flex-shrink-0 ${
|
||||||
|
hasUpdate
|
||||||
|
? 'text-amber-500 dark:text-amber-400'
|
||||||
|
: 'text-[#2563eb] dark:text-[#5b8def]'
|
||||||
|
}`}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7,10 12,15 17,10" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
title={hasUpdate ? t('market.updateAvailable') : undefined}
|
||||||
|
className={`text-xs sm:text-sm font-medium whitespace-nowrap ${
|
||||||
|
showInstalledMark
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: hasUpdate
|
||||||
|
? 'text-amber-600 dark:text-amber-400'
|
||||||
|
: 'text-[#2563eb] dark:text-[#5b8def]'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
{showInstalledMark
|
||||||
<polyline points="7,10 12,15 17,10" />
|
? t('market.installed')
|
||||||
<line x1="12" y1="15" x2="12" y2="3" />
|
: (cardVO.installCount?.toLocaleString() ?? '0')}
|
||||||
</svg>
|
|
||||||
<div className="text-xs sm:text-sm text-[#2563eb] dark:text-[#5b8def] font-medium whitespace-nowrap">
|
|
||||||
{cardVO.installCount?.toLocaleString() ?? '0'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -376,18 +437,11 @@ export default function PluginMarketCardComponent({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cardVO.components && Object.keys(cardVO.components).length > 0 && (
|
{bottomTrailing ? (
|
||||||
<div className="flex flex-row items-center gap-1 flex-shrink-0">
|
<div className="flex flex-row items-center gap-1 flex-shrink-0">
|
||||||
<PluginComponentList
|
{bottomTrailing}
|
||||||
components={cardVO.components}
|
|
||||||
showComponentName={false}
|
|
||||||
showTitle={false}
|
|
||||||
useBadge={true}
|
|
||||||
t={t}
|
|
||||||
responsive={false}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+8
@@ -12,6 +12,10 @@ export interface IPluginMarketCardVO {
|
|||||||
components?: Record<string, number>;
|
components?: Record<string, number>;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
type?: 'plugin' | 'mcp' | 'skill';
|
type?: 'plugin' | 'mcp' | 'skill';
|
||||||
|
/** Whether this extension is already installed in the current workspace. */
|
||||||
|
installed?: boolean;
|
||||||
|
/** Whether the installed extension has a newer marketplace version. */
|
||||||
|
hasUpdate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PluginMarketCardVO implements IPluginMarketCardVO {
|
export class PluginMarketCardVO implements IPluginMarketCardVO {
|
||||||
@@ -28,6 +32,8 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
|
|||||||
components?: Record<string, number>;
|
components?: Record<string, number>;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
type?: 'plugin' | 'mcp' | 'skill';
|
type?: 'plugin' | 'mcp' | 'skill';
|
||||||
|
installed?: boolean;
|
||||||
|
hasUpdate?: boolean;
|
||||||
|
|
||||||
constructor(prop: IPluginMarketCardVO) {
|
constructor(prop: IPluginMarketCardVO) {
|
||||||
this.description = prop.description;
|
this.description = prop.description;
|
||||||
@@ -43,5 +49,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
|
|||||||
this.components = prop.components;
|
this.components = prop.components;
|
||||||
this.tags = prop.tags;
|
this.tags = prop.tags;
|
||||||
this.type = prop.type;
|
this.type = prop.type;
|
||||||
|
this.installed = prop.installed ?? false;
|
||||||
|
this.hasUpdate = prop.hasUpdate ?? false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import PluginDetailContent from './PluginDetailContent';
|
import PluginDetailContent from './PluginDetailContent';
|
||||||
import styles from './plugins.module.css';
|
import styles from './plugins.module.css';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Power, Code, Copy, Check, Bug, Unlink } from 'lucide-react';
|
import { Power, Code, Copy, Check, Bug, Unlink, Search, X } from 'lucide-react';
|
||||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
@@ -63,6 +63,7 @@ function PluginListView() {
|
|||||||
const [copiedDebugUrl, setCopiedDebugUrl] = useState(false);
|
const [copiedDebugUrl, setCopiedDebugUrl] = useState(false);
|
||||||
const [copiedDebugKey, setCopiedDebugKey] = useState(false);
|
const [copiedDebugKey, setCopiedDebugKey] = useState(false);
|
||||||
const [filterType, setFilterType] = useState<FilterType>('all');
|
const [filterType, setFilterType] = useState<FilterType>('all');
|
||||||
|
const [installedSearchQuery, setInstalledSearchQuery] = useState('');
|
||||||
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
|
const pluginInstalledRef = useRef<PluginInstalledComponentRef>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -179,6 +180,27 @@ function PluginListView() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center gap-2 flex-wrap">
|
<div className="flex flex-row items-center gap-2 flex-wrap">
|
||||||
|
{/* Search installed extensions by label / name / author / description */}
|
||||||
|
<div className="relative w-full sm:w-56">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={installedSearchQuery}
|
||||||
|
onChange={(e) => setInstalledSearchQuery(e.target.value)}
|
||||||
|
placeholder={t('plugins.searchInstalled')}
|
||||||
|
aria-label={t('plugins.searchInstalled')}
|
||||||
|
className="pl-9 pr-8 text-sm"
|
||||||
|
/>
|
||||||
|
{installedSearchQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('common.clear')}
|
||||||
|
onClick={() => setInstalledSearchQuery('')}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2 px-1 sm:px-2">
|
<div className="flex items-center gap-2 px-1 sm:px-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="group-by-type"
|
id="group-by-type"
|
||||||
@@ -300,6 +322,8 @@ function PluginListView() {
|
|||||||
ref={pluginInstalledRef}
|
ref={pluginInstalledRef}
|
||||||
filterType={filterType}
|
filterType={filterType}
|
||||||
groupByType={groupByType}
|
groupByType={groupByType}
|
||||||
|
searchQuery={installedSearchQuery}
|
||||||
|
onClearSearch={() => setInstalledSearchQuery('')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -460,6 +460,8 @@ export interface AsyncTask {
|
|||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
task_type: string; // system or user
|
task_type: string; // system or user
|
||||||
|
/** Unix epoch seconds (float) when the task was created. */
|
||||||
|
created_at?: number;
|
||||||
runtime: AsyncTaskRuntimeInfo;
|
runtime: AsyncTaskRuntimeInfo;
|
||||||
task_context: AsyncTaskTaskContext;
|
task_context: AsyncTaskTaskContext;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const enUS = {
|
|||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
add: 'Add',
|
add: 'Add',
|
||||||
select: 'Select',
|
select: 'Select',
|
||||||
|
clear: 'Clear',
|
||||||
skill: 'Skill',
|
skill: 'Skill',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
submit: 'Submit',
|
submit: 'Submit',
|
||||||
@@ -549,6 +550,8 @@ const enUS = {
|
|||||||
getPluginListError: 'Failed to get plugin list:',
|
getPluginListError: 'Failed to get plugin list:',
|
||||||
noPluginInstalled: 'No plugins installed',
|
noPluginInstalled: 'No plugins installed',
|
||||||
noExtensionInstalled: 'No extensions installed',
|
noExtensionInstalled: 'No extensions installed',
|
||||||
|
searchInstalled: 'Search installed extensions',
|
||||||
|
noMatchingExtensions: 'No extensions match "{{query}}"',
|
||||||
loadingExtensions: 'Loading extensions...',
|
loadingExtensions: 'Loading extensions...',
|
||||||
groupByType: 'Group by format',
|
groupByType: 'Group by format',
|
||||||
pluginConfig: 'Plugin Configuration',
|
pluginConfig: 'Plugin Configuration',
|
||||||
@@ -744,6 +747,9 @@ const enUS = {
|
|||||||
allLoadedCount: 'All {{count}} extensions displayed',
|
allLoadedCount: 'All {{count}} extensions displayed',
|
||||||
install: 'Install',
|
install: 'Install',
|
||||||
installCard: 'Install {{name}}',
|
installCard: 'Install {{name}}',
|
||||||
|
installedCard: 'Installed {{name}}',
|
||||||
|
installed: 'Installed',
|
||||||
|
updateAvailable: 'Update available',
|
||||||
installConfirm:
|
installConfirm:
|
||||||
'Are you sure you want to install plugin "{{name}}" ({{version}})?',
|
'Are you sure you want to install plugin "{{name}}" ({{version}})?',
|
||||||
downloadComplete: 'Plugin "{{name}}" download completed',
|
downloadComplete: 'Plugin "{{name}}" download completed',
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const esES = {
|
|||||||
delete: 'Eliminar',
|
delete: 'Eliminar',
|
||||||
add: 'Añadir',
|
add: 'Añadir',
|
||||||
select: 'Seleccionar',
|
select: 'Seleccionar',
|
||||||
|
clear: 'Limpiar',
|
||||||
skill: 'Habilidad',
|
skill: 'Habilidad',
|
||||||
cancel: 'Cancelar',
|
cancel: 'Cancelar',
|
||||||
submit: 'Enviar',
|
submit: 'Enviar',
|
||||||
@@ -566,6 +567,8 @@ const esES = {
|
|||||||
getPluginListError: 'Error al obtener la lista de plugins:',
|
getPluginListError: 'Error al obtener la lista de plugins:',
|
||||||
noPluginInstalled: 'No hay plugins instalados',
|
noPluginInstalled: 'No hay plugins instalados',
|
||||||
noExtensionInstalled: 'No hay extensiones instaladas',
|
noExtensionInstalled: 'No hay extensiones instaladas',
|
||||||
|
searchInstalled: 'Buscar extensiones instaladas',
|
||||||
|
noMatchingExtensions: 'Ninguna extensión coincide con "{{query}}"',
|
||||||
loadingExtensions: 'Cargando extensiones...',
|
loadingExtensions: 'Cargando extensiones...',
|
||||||
groupByType: 'Agrupar por formato',
|
groupByType: 'Agrupar por formato',
|
||||||
pluginConfig: 'Configuración del plugin',
|
pluginConfig: 'Configuración del plugin',
|
||||||
@@ -842,6 +845,9 @@ const esES = {
|
|||||||
noTags: 'No hay etiquetas disponibles',
|
noTags: 'No hay etiquetas disponibles',
|
||||||
},
|
},
|
||||||
installCard: 'Instalar {{name}}',
|
installCard: 'Instalar {{name}}',
|
||||||
|
installedCard: '{{name}} instalado',
|
||||||
|
installed: 'Instalado',
|
||||||
|
updateAvailable: 'Actualización disponible',
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
title: 'MCP',
|
title: 'MCP',
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const jaJP = {
|
|||||||
delete: '削除',
|
delete: '削除',
|
||||||
add: '追加',
|
add: '追加',
|
||||||
select: '選択してください',
|
select: '選択してください',
|
||||||
|
clear: 'クリア',
|
||||||
skill: 'スキル',
|
skill: 'スキル',
|
||||||
cancel: 'キャンセル',
|
cancel: 'キャンセル',
|
||||||
submit: '送信',
|
submit: '送信',
|
||||||
@@ -557,6 +558,8 @@ const jaJP = {
|
|||||||
getPluginListError: 'プラグインリストの取得に失敗しました:',
|
getPluginListError: 'プラグインリストの取得に失敗しました:',
|
||||||
noPluginInstalled: 'プラグインがインストールされていません',
|
noPluginInstalled: 'プラグインがインストールされていません',
|
||||||
noExtensionInstalled: '拡張機能がインストールされていません',
|
noExtensionInstalled: '拡張機能がインストールされていません',
|
||||||
|
searchInstalled: 'インストール済み拡張機能を検索',
|
||||||
|
noMatchingExtensions: '「{{query}}」に一致する拡張機能はありません',
|
||||||
loadingExtensions: '拡張機能を読み込み中...',
|
loadingExtensions: '拡張機能を読み込み中...',
|
||||||
groupByType: '形式でグループ化',
|
groupByType: '形式でグループ化',
|
||||||
pluginConfig: 'プラグイン設定',
|
pluginConfig: 'プラグイン設定',
|
||||||
@@ -831,6 +834,9 @@ const jaJP = {
|
|||||||
deprecatedTooltip:
|
deprecatedTooltip:
|
||||||
'対応する「ナレッジエンジン」プラグインをインストールしてください。',
|
'対応する「ナレッジエンジン」プラグインをインストールしてください。',
|
||||||
installCard: '{{name}} をインストール',
|
installCard: '{{name}} をインストール',
|
||||||
|
installedCard: '{{name}} はインストール済み',
|
||||||
|
installed: 'インストール済み',
|
||||||
|
updateAvailable: '更新があります',
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
title: 'MCP',
|
title: 'MCP',
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const ruRU = {
|
|||||||
delete: 'Удалить',
|
delete: 'Удалить',
|
||||||
add: 'Добавить',
|
add: 'Добавить',
|
||||||
select: 'Выбрать',
|
select: 'Выбрать',
|
||||||
|
clear: 'Очистить',
|
||||||
skill: 'Навык',
|
skill: 'Навык',
|
||||||
cancel: 'Отмена',
|
cancel: 'Отмена',
|
||||||
submit: 'Отправить',
|
submit: 'Отправить',
|
||||||
@@ -563,6 +564,8 @@ const ruRU = {
|
|||||||
getPluginListError: 'Не удалось получить список плагинов:',
|
getPluginListError: 'Не удалось получить список плагинов:',
|
||||||
noPluginInstalled: 'Плагины не установлены',
|
noPluginInstalled: 'Плагины не установлены',
|
||||||
noExtensionInstalled: 'Расширения не установлены',
|
noExtensionInstalled: 'Расширения не установлены',
|
||||||
|
searchInstalled: 'Поиск установленных расширений',
|
||||||
|
noMatchingExtensions: 'Нет расширений, соответствующих «{{query}}»',
|
||||||
loadingExtensions: 'Загрузка расширений...',
|
loadingExtensions: 'Загрузка расширений...',
|
||||||
groupByType: 'Группировать по формату',
|
groupByType: 'Группировать по формату',
|
||||||
pluginConfig: 'Настройка плагина',
|
pluginConfig: 'Настройка плагина',
|
||||||
@@ -836,6 +839,9 @@ const ruRU = {
|
|||||||
noTags: 'Нет доступных тегов',
|
noTags: 'Нет доступных тегов',
|
||||||
},
|
},
|
||||||
installCard: 'Установить {{name}}',
|
installCard: 'Установить {{name}}',
|
||||||
|
installedCard: '{{name}} установлен',
|
||||||
|
installed: 'Установлено',
|
||||||
|
updateAvailable: 'Доступно обновление',
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
title: 'MCP',
|
title: 'MCP',
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const thTH = {
|
|||||||
delete: 'ลบ',
|
delete: 'ลบ',
|
||||||
add: 'เพิ่ม',
|
add: 'เพิ่ม',
|
||||||
select: 'เลือก',
|
select: 'เลือก',
|
||||||
|
clear: 'ล้าง',
|
||||||
skill: 'สกิล',
|
skill: 'สกิล',
|
||||||
cancel: 'ยกเลิก',
|
cancel: 'ยกเลิก',
|
||||||
submit: 'ส่ง',
|
submit: 'ส่ง',
|
||||||
@@ -547,6 +548,8 @@ const thTH = {
|
|||||||
getPluginListError: 'ไม่สามารถดึงรายการปลั๊กอินได้:',
|
getPluginListError: 'ไม่สามารถดึงรายการปลั๊กอินได้:',
|
||||||
noPluginInstalled: 'ยังไม่มีปลั๊กอินที่ติดตั้ง',
|
noPluginInstalled: 'ยังไม่มีปลั๊กอินที่ติดตั้ง',
|
||||||
noExtensionInstalled: 'ยังไม่มีส่วนขยายที่ติดตั้ง',
|
noExtensionInstalled: 'ยังไม่มีส่วนขยายที่ติดตั้ง',
|
||||||
|
searchInstalled: 'ค้นหาส่วนขยายที่ติดตั้งแล้ว',
|
||||||
|
noMatchingExtensions: 'ไม่มีส่วนขยายที่ตรงกับ "{{query}}"',
|
||||||
loadingExtensions: 'กำลังโหลดส่วนขยาย...',
|
loadingExtensions: 'กำลังโหลดส่วนขยาย...',
|
||||||
groupByType: 'จัดกลุ่มตามรูปแบบ',
|
groupByType: 'จัดกลุ่มตามรูปแบบ',
|
||||||
pluginConfig: 'การกำหนดค่าปลั๊กอิน',
|
pluginConfig: 'การกำหนดค่าปลั๊กอิน',
|
||||||
@@ -813,6 +816,9 @@ const thTH = {
|
|||||||
noTags: 'ไม่มีแท็กที่พร้อมใช้งาน',
|
noTags: 'ไม่มีแท็กที่พร้อมใช้งาน',
|
||||||
},
|
},
|
||||||
installCard: 'ติดตั้ง {{name}}',
|
installCard: 'ติดตั้ง {{name}}',
|
||||||
|
installedCard: 'ติดตั้ง {{name}} แล้ว',
|
||||||
|
installed: 'ติดตั้งแล้ว',
|
||||||
|
updateAvailable: 'มีอัปเดต',
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
title: 'MCP',
|
title: 'MCP',
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const viVN = {
|
|||||||
delete: 'Xóa',
|
delete: 'Xóa',
|
||||||
add: 'Thêm',
|
add: 'Thêm',
|
||||||
select: 'Chọn',
|
select: 'Chọn',
|
||||||
|
clear: 'Xóa',
|
||||||
skill: 'Kỹ năng',
|
skill: 'Kỹ năng',
|
||||||
cancel: 'Hủy',
|
cancel: 'Hủy',
|
||||||
submit: 'Gửi',
|
submit: 'Gửi',
|
||||||
@@ -557,6 +558,8 @@ const viVN = {
|
|||||||
getPluginListError: 'Lấy danh sách plugin thất bại:',
|
getPluginListError: 'Lấy danh sách plugin thất bại:',
|
||||||
noPluginInstalled: 'Chưa cài đặt plugin nào',
|
noPluginInstalled: 'Chưa cài đặt plugin nào',
|
||||||
noExtensionInstalled: 'Chưa cài đặt tiện ích mở rộng nào',
|
noExtensionInstalled: 'Chưa cài đặt tiện ích mở rộng nào',
|
||||||
|
searchInstalled: 'Tìm tiện ích mở rộng đã cài đặt',
|
||||||
|
noMatchingExtensions: 'Không có tiện ích mở rộng nào khớp với "{{query}}"',
|
||||||
loadingExtensions: 'Đang tải tiện ích mở rộng...',
|
loadingExtensions: 'Đang tải tiện ích mở rộng...',
|
||||||
groupByType: 'Nhóm theo định dạng',
|
groupByType: 'Nhóm theo định dạng',
|
||||||
pluginConfig: 'Cấu hình Plugin',
|
pluginConfig: 'Cấu hình Plugin',
|
||||||
@@ -828,6 +831,9 @@ const viVN = {
|
|||||||
noTags: 'Không có thẻ nào',
|
noTags: 'Không có thẻ nào',
|
||||||
},
|
},
|
||||||
installCard: 'Cài đặt {{name}}',
|
installCard: 'Cài đặt {{name}}',
|
||||||
|
installedCard: 'Đã cài đặt {{name}}',
|
||||||
|
installed: 'Đã cài đặt',
|
||||||
|
updateAvailable: 'Có bản cập nhật',
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
title: 'MCP',
|
title: 'MCP',
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const zhHans = {
|
|||||||
delete: '删除',
|
delete: '删除',
|
||||||
add: '添加',
|
add: '添加',
|
||||||
select: '请选择',
|
select: '请选择',
|
||||||
|
clear: '清除',
|
||||||
skill: '技能',
|
skill: '技能',
|
||||||
cancel: '取消',
|
cancel: '取消',
|
||||||
submit: '提交',
|
submit: '提交',
|
||||||
@@ -528,6 +529,8 @@ const zhHans = {
|
|||||||
pluginConfig: '插件配置',
|
pluginConfig: '插件配置',
|
||||||
noPluginInstalled: '暂未安装任何插件',
|
noPluginInstalled: '暂未安装任何插件',
|
||||||
noExtensionInstalled: '暂未安装任何扩展',
|
noExtensionInstalled: '暂未安装任何扩展',
|
||||||
|
searchInstalled: '搜索已安装扩展',
|
||||||
|
noMatchingExtensions: '没有匹配「{{query}}」的扩展',
|
||||||
loadingExtensions: '正在加载扩展...',
|
loadingExtensions: '正在加载扩展...',
|
||||||
groupByType: '按格式分组',
|
groupByType: '按格式分组',
|
||||||
pluginSort: '插件排序',
|
pluginSort: '插件排序',
|
||||||
@@ -712,6 +715,9 @@ const zhHans = {
|
|||||||
allLoadedCount: '已显示全部 {{count}} 个扩展',
|
allLoadedCount: '已显示全部 {{count}} 个扩展',
|
||||||
install: '安装',
|
install: '安装',
|
||||||
installCard: '安装 {{name}}',
|
installCard: '安装 {{name}}',
|
||||||
|
installedCard: '已安装 {{name}}',
|
||||||
|
installed: '已安装',
|
||||||
|
updateAvailable: '有可用更新',
|
||||||
installConfirm: '确定要安装插件 "{{name}}" ({{version}}) 吗?',
|
installConfirm: '确定要安装插件 "{{name}}" ({{version}}) 吗?',
|
||||||
downloadComplete: '插件 "{{name}}" 下载完成',
|
downloadComplete: '插件 "{{name}}" 下载完成',
|
||||||
installFailed: '安装失败,请稍后重试',
|
installFailed: '安装失败,请稍后重试',
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const zhHant = {
|
|||||||
delete: '刪除',
|
delete: '刪除',
|
||||||
add: '新增',
|
add: '新增',
|
||||||
select: '請選擇',
|
select: '請選擇',
|
||||||
|
clear: '清除',
|
||||||
skill: '技能',
|
skill: '技能',
|
||||||
cancel: '取消',
|
cancel: '取消',
|
||||||
submit: '提交',
|
submit: '提交',
|
||||||
@@ -532,6 +533,8 @@ const zhHant = {
|
|||||||
pluginConfig: '外掛設定',
|
pluginConfig: '外掛設定',
|
||||||
noPluginInstalled: '暫未安裝任何外掛',
|
noPluginInstalled: '暫未安裝任何外掛',
|
||||||
noExtensionInstalled: '暫未安裝任何擴充功能',
|
noExtensionInstalled: '暫未安裝任何擴充功能',
|
||||||
|
searchInstalled: '搜尋已安裝擴充功能',
|
||||||
|
noMatchingExtensions: '沒有符合「{{query}}」的擴充功能',
|
||||||
loadingExtensions: '正在載入擴充功能...',
|
loadingExtensions: '正在載入擴充功能...',
|
||||||
groupByType: '依格式分組',
|
groupByType: '依格式分組',
|
||||||
pluginSort: '外掛排序',
|
pluginSort: '外掛排序',
|
||||||
@@ -715,6 +718,9 @@ const zhHant = {
|
|||||||
allLoadedCount: '已顯示全部 {{count}} 個擴展',
|
allLoadedCount: '已顯示全部 {{count}} 個擴展',
|
||||||
install: '安裝',
|
install: '安裝',
|
||||||
installCard: '安裝 {{name}}',
|
installCard: '安裝 {{name}}',
|
||||||
|
installedCard: '已安裝 {{name}}',
|
||||||
|
installed: '已安裝',
|
||||||
|
updateAvailable: '有可用更新',
|
||||||
installConfirm: '確定要安裝插件 "{{name}}" ({{version}}) 嗎?',
|
installConfirm: '確定要安裝插件 "{{name}}" ({{version}}) 嗎?',
|
||||||
downloadComplete: '插件 "{{name}}" 下載完成',
|
downloadComplete: '插件 "{{name}}" 下載完成',
|
||||||
installFailed: '安裝失敗,請稍後重試',
|
installFailed: '安裝失敗,請稍後重試',
|
||||||
|
|||||||
Reference in New Issue
Block a user