diff --git a/.changeset/interaction-quality.md b/.changeset/interaction-quality.md new file mode 100644 index 00000000..37df82c7 --- /dev/null +++ b/.changeset/interaction-quality.md @@ -0,0 +1,6 @@ +--- +'@inkcre/client-web': patch +'@inkcre/core': patch +--- + +按任务精简 Settings、Peer 与 Extension 界面,统一页面和浏览器标题,补齐场景化加载、刷新失败保留、保存与取消的动作反馈。修正重复检索、任务不存在及日志读取失败的可观察结果,保留失败后的恢复入口。 diff --git a/.changeset/schema-forms-tabs.md b/.changeset/schema-forms-tabs.md new file mode 100644 index 00000000..01d5bf10 --- /dev/null +++ b/.changeset/schema-forms-tabs.md @@ -0,0 +1,10 @@ +--- +'@inkcre/client-web': patch +'@inkcre/core': patch +'@inkcre/ext-dev-utils': patch +'@inkcre/ext-mail': patch +'@inkcre/ext-memos': patch +'@inkcre/ext-twitter': patch +--- + +Source 与 Peer 配置使用可视化表单,保留完整 JSON 编辑模式;Peers 页面可确认删除离线 Peer 并清理其扩展启用记录。Extension 浏览与版本选择交由 Registry,Web 接收精确版本并确认安装;Web 与 Module Federation 扩展统一升级正式 UI 2.2.0,Peer AI 凭据在 schema 中标记为密码输入。 diff --git a/apps/client-web/AGENTS.md b/apps/client-web/AGENTS.md index 9961e17f..b707bbc3 100644 --- a/apps/client-web/AGENTS.md +++ b/apps/client-web/AGENTS.md @@ -13,8 +13,8 @@ Static Vue application and equal InKCre database Peer. ## Local Hazards - Keep deployable artifacts environment-neutral: no service origin, Peer identity, JWT secret, Worker runtime, or `/api/config` fallback. -- Browser-owned settings are runtime authority. Keep the JWT signing secret masked, memory-only after use, absent from logs, and excluded from portable exports. -- Use **Peer** in technical contracts; product-facing UI may say **client**. +- Browser-owned settings are runtime authority. Keep the JWT signing secret masked during ordinary display and absent from logs. The explicit full-browser export includes it so another browser can restore the same experience. +- Use **Peer** for InKCre runtime nodes in both technical contracts and product UI. Keep “client” only for actual application or protocol client roles. - Preserve exact capability delegation and typed unknown outcomes; do not replace them with a generic Core API path or automatic retry after ambiguous dispatch. - Start development through root commands so SVC and Portless preserve worktree identity. The sibling UI source lane is explicitly non-release evidence. diff --git a/apps/client-web/package.json b/apps/client-web/package.json index 3f8d2d0c..c7926ac1 100644 --- a/apps/client-web/package.json +++ b/apps/client-web/package.json @@ -12,8 +12,8 @@ }, "dependencies": { "@inkcre/core": "workspace:*", - "@inkcre/extension-runtime-client-web": "https://github.com/InKCre/ext-reg/releases/download/runtime-client-web-v0.2.0/inkcre-extension-runtime-client-web-0.2.0.tgz", - "@inkcre/ui-web": "2.0.1", + "@inkcre/extension-runtime-client-web": "github:InKCre/ext-reg#57dcd80289285620a1fd27a0a48027e56c4ce7b1&path:/runtimes/client-web", + "@inkcre/ui-web": "2.2.0", "@module-federation/runtime": "^0.21.4", "@supabase/postgrest-js": "^2.84.0", "@vue-flow/core": "^1.48.1", diff --git a/apps/client-web/src/App.vue b/apps/client-web/src/App.vue index 5cd6be21..e40cd170 100644 --- a/apps/client-web/src/App.vue +++ b/apps/client-web/src/App.vue @@ -1,23 +1,95 @@ diff --git a/apps/client-web/src/components/AGENTS.md b/apps/client-web/src/components/AGENTS.md index 9ce15906..f70bcdc4 100644 --- a/apps/client-web/src/components/AGENTS.md +++ b/apps/client-web/src/components/AGENTS.md @@ -8,7 +8,7 @@ Scope: `apps/client-web/src/components/**`. component-local copy of a model merely to shape props or emits. - Solved-content renderer selection belongs to the core `Resolver` registration. Keep `SolvedContentRenderer` as a dynamic handoff; do not add a second resolver-to-component map here. -- Keep technical names under `peer/`; user-facing copy may use “client”. +- Keep InKCre runtime-node names under `peer/` and use “Peer” in user-facing copy. Use “client” only for actual application or protocol client roles. General component file guidance lives in [component.instructions.md](../../../../.github/instructions/component.instructions.md). Stable diff --git a/apps/client-web/src/components/common/AppSidePanel/AppSidePanel.vue b/apps/client-web/src/components/common/AppSidePanel/AppSidePanel.vue index 2ee2b5d3..a44ccf68 100644 --- a/apps/client-web/src/components/common/AppSidePanel/AppSidePanel.vue +++ b/apps/client-web/src/components/common/AppSidePanel/AppSidePanel.vue @@ -24,6 +24,10 @@ const onSettingsClick = () => { router.push('/settings') } +const onPeersClick = () => { + router.push('/peers') +} + const onExploreClick = () => { router.push('/info-base/graph') } @@ -43,6 +47,7 @@ watch(

{{ t('sidePanel.managing') }}

+ diff --git a/apps/client-web/src/components/extension/extensionCard/extensionCard.md b/apps/client-web/src/components/extension/extensionCard/extensionCard.md index 9b1aafab..7ef01361 100644 --- a/apps/client-web/src/components/extension/extensionCard/extensionCard.md +++ b/apps/client-web/src/components/extension/extensionCard/extensionCard.md @@ -1,29 +1,28 @@ # ExtensionCard -A component that displays extension information and provides controls for toggling enable/disable status and editing configuration. +A component that displays an installed Extension and opens focused Peer selection for enablement changes. ## Props - `extension` (InstalledExtension, required): the canonical installed row -- `enabled` (boolean, required): whether the selected Client's Peer UUID is in `enabled[]` -- `controlsCurrentWebRuntime` (boolean, required): whether the switch owns this browser's runtime -- `canChangeVersion` (boolean, required): whether the selected Host can validate a version change -- `changeVersion` (function, required): application-level version change through the selected Host -- `setEnabled` (function, required): application-level selected-Client control operation +- `peers` (Peer[], required): available Peers, including this browser +- `livePeerIds` (ReadonlySet, required): server-evaluated online Peers used for management routing +- `currentPeerId` (string, required): identifies this browser's running Host +- `peerSelectionDisabled` (boolean): blocks actions while the Peer list is unavailable ## Emits -- `updated`: Emitted with the canonical row after configuration or version changes +- `updated`: Emitted with the canonical row after Peer, configuration, or version changes - `uninstalled`: Emitted after the canonical row is removed ## Features - Display canonical Extension Name, exact version, and optional nickname -- Toggle enable/disable status with a switch +- Choose one or more eligible Peers in an enable or disable dialog. Each selected Peer uses the existing current Host, remote Host, or durable-intent path. Partial failures remain visible and trigger a fresh installed-row read; no batch rollback or automatic retry occurs. - Mount an Extension-owned setup contribution from this browser's running Web Distribution -- Keep setup availability independent of which Client is selected for enablement control +- Keep setup availability independent of the Peer selection dialog; disabling this browser closes its setup surface first - Link the exact installed release's available global, Core, and Web documentation even when the extension is disabled or has no browser distribution; distinguish missing documentation from failed discovery - Edit extension configuration via JSON editor in a dialog -- Change the exact shared version through the selected Host only while every Peer is disabled +- Select a published Registry Release and change the exact shared version only while every Peer is disabled - Auto-formats configuration as JSON for easier editing - Prevents uninstall while any Peer remains enabled diff --git a/apps/client-web/src/components/extension/extensionCard/extensionCard.scss b/apps/client-web/src/components/extension/extensionCard/extensionCard.scss index b0a792cd..791ec52c 100644 --- a/apps/client-web/src/components/extension/extensionCard/extensionCard.scss +++ b/apps/client-web/src/components/extension/extensionCard/extensionCard.scss @@ -37,6 +37,18 @@ flex-wrap: wrap; gap: sys-var(space, sm); } + &__details { + @include apply-font(body-sm); + summary { + cursor: pointer; + } + &[open] > summary { + margin-bottom: sys-var(space, sm); + } + .extension-card__hint { + margin-top: sys-var(space, sm); + } + } &__hint { @include apply-font(body-sm); @@ -49,6 +61,38 @@ color: sys-var(color, feedback, error); margin: 0; } + + &__peer-options { + display: flex; + flex-direction: column; + gap: sys-var(space, sm); + max-height: min(50vh, 24rem); + overflow-y: auto; + } + + &__peer-option { + display: flex; + align-items: start; + gap: sys-var(space, sm); + padding: sys-var(space, sm); + border: 1px solid sys-var(color, border, subtle); + cursor: pointer; + + input { + margin-top: 0.15em; + } + } + + &__peer-note { + display: block; + color: sys-var(color, text, subtle); + } + + &__loading { + display: grid; + min-height: 5rem; + place-items: center; + } } .config-popup { diff --git a/apps/client-web/src/components/extension/extensionCard/extensionCard.ts b/apps/client-web/src/components/extension/extensionCard/extensionCard.ts index 87b26726..9900293a 100644 --- a/apps/client-web/src/components/extension/extensionCard/extensionCard.ts +++ b/apps/client-web/src/components/extension/extensionCard/extensionCard.ts @@ -1,20 +1,13 @@ import { type PropType } from 'vue' -import type { InstalledExtension } from '@inkcre/core' +import type { InstalledExtension, Peer } from '@inkcre/core' // --- Props --- export const extensionCardProps = { extension: { type: Object as PropType, required: true }, - enabled: { type: Boolean, required: true }, - controlsCurrentWebRuntime: { type: Boolean, required: true }, - canChangeVersion: { type: Boolean, required: true }, - changeVersion: { - type: Function as PropType<(version: string) => Promise>, - required: true, - }, - setEnabled: { - type: Function as PropType<(enabled: boolean) => Promise>, - required: true, - }, + peers: { type: Array as PropType, required: true }, + livePeerIds: { type: Object as PropType>, required: true }, + currentPeerId: { type: String, required: true }, + peerSelectionDisabled: { type: Boolean, default: false }, } as const // --- Emits --- diff --git a/apps/client-web/src/components/extension/extensionCard/extensionCard.vue b/apps/client-web/src/components/extension/extensionCard/extensionCard.vue index bccd3bea..edf50c31 100644 --- a/apps/client-web/src/components/extension/extensionCard/extensionCard.vue +++ b/apps/client-web/src/components/extension/extensionCard/extensionCard.vue @@ -3,19 +3,21 @@ import { ref, computed, nextTick, shallowRef, watch, type Component } from 'vue' import { useI18n } from 'vue-i18n' import { InkButton, - InkSwitch, InkDialog, - InkInput, + InkDropdown, InkJsonEditor, + InkLoading, type JsonEditorValidation, } from '@inkcre/ui-web' -import { getExtensionHost, getExtensionSetupContribution } from '@/core' -import { configStore, ExtensionRegistryOriginResolver } from '@inkcre/core' +import { getExtensionHost, getExtensionRegistry, getExtensionSetupContribution } from '@/core' +import { configStore, ExtensionRegistryOriginResolver, type Peer } from '@inkcre/core' import { getExtensionDocumentation, + sortPublishedReleases, type ExtensionDocumentationLink, } from '@inkcre/extension-runtime-client-web' import { extensionCardProps, extensionCardEmits } from './extensionCard' +import { extensionPeerControlMode, setExtensionPeerEnabled } from '@/extension-peer-control' const props = defineProps(extensionCardProps) const emit = defineEmits(extensionCardEmits) @@ -29,12 +31,21 @@ const versionPopupOpen = ref>(false) const setupPopupOpen = ref(false) const setupComponent = shallowRef(null) const setupContribution = shallowRef(getExtensionSetupContribution(props.extension.name)) -const togglePromise = ref | null>(null) +const peerDialogOpen = ref(false) +const peerAction = ref<'enable' | 'disable'>('enable') +const selectedPeerIds = ref([]) +const peerActionErrors = ref([]) +const updatingPeers = ref(false) const isUninstalling = ref(false) const operationError = ref(null) const configModel = ref(JSON.stringify(props.extension.config, null, 2)) const versionModel = ref(props.extension.version) +const versionOptions = ref<{ label: string; value: string; description?: string }[]>([]) +const versionLoading = ref(false) +const versionError = ref(null) const documentation = shallowRef([]) +const documentationOpen = ref(false) +const documentationRetry = ref(0) const documentationStatus = ref<'loading' | 'available' | 'missing' | 'unavailable'>('loading') const canSaveConfig = computed( () => @@ -43,8 +54,15 @@ const canSaveConfig = computed( // --- computed --- watch( - () => [props.extension.name, props.extension.version] as const, - async ([name, version], _previous, onCleanup) => { + () => + [ + props.extension.name, + props.extension.version, + documentationOpen.value, + documentationRetry.value, + ] as const, + async ([name, version, open], _previous, onCleanup) => { + if (!open) return let current = true onCleanup(() => { current = false @@ -75,32 +93,79 @@ watch( ) const canUninstall = computed(() => props.extension.enabled.length === 0 && !isUninstalling.value) +const eligiblePeers = computed(() => + props.peers.filter((peer) => { + const alreadyEnabled = props.extension.enabled.includes(peer.id) + return peerAction.value === 'disable' ? alreadyEnabled : !alreadyEnabled + }) +) +const hasDeferredSelection = computed(() => + eligiblePeers.value.some( + (peer) => + selectedPeerIds.value.includes(peer.id) && + extensionPeerControlMode(peer, props.currentPeerId, props.livePeerIds) === 'desired-state' + ) +) +const peerLabel = (peer: Peer) => + `${peer.name} · ${peer.application_version ? `v${peer.application_version}` : t('peer.versionUnknown')}` const closeSetup = async () => { setupPopupOpen.value = false setupComponent.value = null await nextTick() } -const toggleModel = computed({ - get: () => (togglePromise.value ? togglePromise.value : props.enabled), - set: (enabled: boolean) => { - operationError.value = null - togglePromise.value = (async () => { +const openPeerDialog = (action: 'enable' | 'disable') => { + peerAction.value = action + selectedPeerIds.value = [] + peerActionErrors.value = [] + operationError.value = null + peerDialogOpen.value = true +} + +const applyPeerAction = async () => { + if (updatingPeers.value || selectedPeerIds.value.length === 0) return + const selectedPeers = eligiblePeers.value.filter((peer) => + selectedPeerIds.value.includes(peer.id) + ) + if (selectedPeers.length === 0) return + const enabled = peerAction.value === 'enable' + updatingPeers.value = true + peerActionErrors.value = [] + try { + if (!enabled && selectedPeerIds.value.includes(props.currentPeerId)) await closeSetup() + for (const peer of selectedPeers) { try { - if (!enabled && props.controlsCurrentWebRuntime) await closeSetup() - const updated = await props.setEnabled(enabled) + const updated = await setExtensionPeerEnabled({ + name: props.extension.name, + peer, + currentPeerId: props.currentPeerId, + livePeerIds: props.livePeerIds, + enabled, + manager: getExtensionHost(), + }) emit('updated', updated) - return enabled } catch (error) { - operationError.value = error instanceof Error ? error.message : String(error) - return props.enabled - } finally { - setupContribution.value = getExtensionSetupContribution(props.extension.name) - togglePromise.value = null + peerActionErrors.value.push( + `${peerLabel(peer)}: ${error instanceof Error ? error.message : String(error)}` + ) } - })() - }, -}) + } + if (peerActionErrors.value.length) { + try { + const latest = await getExtensionHost().get(props.extension.name) + if (latest) emit('updated', latest) + } catch (error) { + peerActionErrors.value.push(error instanceof Error ? error.message : String(error)) + } + selectedPeerIds.value = [] + } else { + peerDialogOpen.value = false + } + } finally { + setupContribution.value = getExtensionSetupContribution(props.extension.name) + updatingPeers.value = false + } +} const onEditConfigClick = () => { configModel.value = JSON.stringify(props.extension.config, null, 2) @@ -116,16 +181,40 @@ const onSetupClick = () => { setupPopupOpen.value = true } -const onChangeVersionClick = () => { +const onChangeVersionClick = async () => { versionModel.value = props.extension.version + versionOptions.value = [] + versionError.value = null versionPopupOpen.value = true + versionLoading.value = true + try { + const record = await getExtensionRegistry().getExtension(props.extension.name) + versionOptions.value = sortPublishedReleases(record.releases ?? []).map((release) => ({ + label: release.version, + value: release.version, + description: [ + release.python ? t('extension.hostCore') : null, + release.module_federation ? t('extension.hostWeb') : null, + ] + .filter(Boolean) + .join(' · '), + })) + } catch (error) { + versionError.value = error instanceof Error ? error.message : String(error) + } finally { + versionLoading.value = false + } } const onConfirmVersion = () => { + if (versionLoading.value || versionError.value || !versionModel.value) return versionPopupOpen.value = (async () => { try { operationError.value = null - const updatedExtension = await props.changeVersion(versionModel.value.trim()) + const updatedExtension = await getExtensionHost().changeVersion( + props.extension.name, + versionModel.value + ) emit('updated', updatedExtension) return false } catch (error) { @@ -182,57 +271,159 @@ const onUninstall = async () => { {{ extension.nickname }} -
- +
- -

+

{{ t('extension.enabledPeerCount', { count: extension.enabled.length }) }} - {{ t('extension.uninstallDisabled') }}

-

{{ operationError }}

- +

+ {{ t('extension.documentationUnavailable') }} +

+

+ {{ t('extension.documentationMissing') }} +

+ + + + +
+ +
+

+ {{ t('extension.desiredStateExplanation') }} +

+ + +
{ - + + + + diff --git a/apps/client-web/src/components/extension/installExtension/installExtension.md b/apps/client-web/src/components/extension/installExtension/installExtension.md deleted file mode 100644 index f5602785..00000000 --- a/apps/client-web/src/components/extension/installExtension/installExtension.md +++ /dev/null @@ -1,16 +0,0 @@ -# InstallExtension - -A component that provides a form interface for installing new extensions. - -## Emits - -- `install`: Emitted after an extension is successfully installed - -## Features - -- Inline form for one canonical `namespace/name` and exact version -- Install button with loading state -- The parent supplies installation on the selected Host. The browser requires a Web distribution; - a live Core validates its Python distribution. Installation does not enable either Host. -- Offline installation is disabled; failures retain the input and never select another Host. -- Automatic form reset after successful installation diff --git a/apps/client-web/src/components/extension/installExtension/installExtension.scss b/apps/client-web/src/components/extension/installExtension/installExtension.scss deleted file mode 100644 index feffbef3..00000000 --- a/apps/client-web/src/components/extension/installExtension/installExtension.scss +++ /dev/null @@ -1,42 +0,0 @@ -.install-extension { - display: flex; - flex-direction: column; - gap: sys-var(space, md); - padding: sys-var(space, md); - box-sizing: border-box; - min-width: 0; - flex: 1 1 24rem; - width: 100%; - - .title { - @include apply-font(title-sm); - color: sys-var(color, text, base); - margin: 0; - width: 100%; - } - - .form { - display: flex; - flex-direction: column; - gap: sys-var(space, sm); - padding: 0; - box-sizing: border-box; - width: 100%; - } - - .footer { - display: flex; - align-items: center; - justify-content: flex-end; - padding: sys-var(space, sm); - padding-top: 0; - box-sizing: border-box; - width: 100%; - } - - &__error { - @include apply-font(body-sm); - color: sys-var(color, feedback, error); - margin: 0; - } -} diff --git a/apps/client-web/src/components/extension/installExtension/installExtension.ts b/apps/client-web/src/components/extension/installExtension/installExtension.ts deleted file mode 100644 index ecbeda4d..00000000 --- a/apps/client-web/src/components/extension/installExtension/installExtension.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { PropType } from 'vue' -import type { InstalledExtension, InstallExtensionInput } from '@inkcre/core' - -export const installExtensionProps = { - install: { - type: Function as PropType<(coordinate: InstallExtensionInput) => Promise>, - required: true, - }, - disabled: { type: Boolean, default: false }, -} as const - -// --- Emits --- -export const installExtensionEmits = { - install: () => true, - busy: (_value: boolean) => true, -} as const diff --git a/apps/client-web/src/components/extension/installExtension/installExtension.vue b/apps/client-web/src/components/extension/installExtension/installExtension.vue deleted file mode 100644 index d67b5dff..00000000 --- a/apps/client-web/src/components/extension/installExtension/installExtension.vue +++ /dev/null @@ -1,73 +0,0 @@ - - - - - diff --git a/apps/client-web/src/components/source/sourceForm/sourceForm.md b/apps/client-web/src/components/source/sourceForm/sourceForm.md index e70aaa39..a018729c 100644 --- a/apps/client-web/src/components/source/sourceForm/sourceForm.md +++ b/apps/client-web/src/components/source/sourceForm/sourceForm.md @@ -1,6 +1,6 @@ # SourceForm -来源创建与详情共用的纵向表单,接收 `modelValue: Source | SourceForm` 和 `disabled`。昵称与类型修改传入的草稿对象;调用方必须提供独立草稿,不能把已保存对象直接传入。配置保留为组件内部的原始 JSON 文本,包括尚未完整或无效的内容。 +来源创建与详情共用的纵向表单,接收 `modelValue: Source | SourceForm` 和 `disabled`。昵称与类型修改传入的草稿对象;调用方必须提供独立草稿,不能把已保存对象直接传入。配置保留为组件内部的一份 JSON 文本草稿,包括尚未完整或无效的内容。支持的 schema 默认呈现可视化字段,可切换到 JSON;不支持的结构仍可使用 JSON 编辑,切换视图不丢弃草稿。 表单读取可用来源类型,并使用所选类型的配置 schema 校验。读取失败或类型不可用时直接说明问题并阻止保存;重试读取不会覆盖输入。`canSave` 仅在类型可用且当前文本验证通过时成立,`readConfig()` 在该边界解析配置。切换类型立即使旧校验失效。 diff --git a/apps/client-web/src/components/source/sourceForm/sourceForm.vue b/apps/client-web/src/components/source/sourceForm/sourceForm.vue index d548a949..0b156936 100644 --- a/apps/client-web/src/components/source/sourceForm/sourceForm.vue +++ b/apps/client-web/src/components/source/sourceForm/sourceForm.vue @@ -3,12 +3,13 @@ import { computed, ref, watch } from 'vue' import { useAsyncState } from '@vueuse/core' import { InkInput, - InkJsonEditor, InkDropdown, InkForm, InkButton, + InkLoading, type JsonEditorValidation, } from '@inkcre/ui-web' +import SchemaConfigEditor from '@/components/schemaConfigEditor/schemaConfigEditor.vue' import { sourceFormProps } from './sourceForm' import { SourceType } from '@inkcre/core' import { useI18n } from 'vue-i18n' @@ -83,7 +84,7 @@ defineExpose({ canSave, isDirty, readConfig, sourceTypes }) :label="t('source.type')" :disabled="disabled || typesLoading" /> -

{{ t('source.typesLoading') }}

+
@@ -95,7 +96,7 @@ defineExpose({ canSave, isDirty, readConfig, sourceTypes }) > {{ t('source.typeUnavailable') }}

- (null) + +/** Detail pages contribute a saved object name; App owns route labels and document.title. */ +export function usePageObjectTitle(title: ComputedRef): void { + const route = useRoute() + let contribution: typeof pageObjectTitle.value = null + watchEffect(() => { + contribution = title.value ? { path: route.fullPath, value: title.value } : null + pageObjectTitle.value = contribution + }) + onScopeDispose(() => { + if (pageObjectTitle.value === contribution) pageObjectTitle.value = null + }) +} diff --git a/apps/client-web/src/core.ts b/apps/client-web/src/core.ts index 506ed40e..5a1b70ef 100644 --- a/apps/client-web/src/core.ts +++ b/apps/client-web/src/core.ts @@ -2,12 +2,13 @@ * Core Package Integration for client-web * * This file initializes @inkcre/core with client-web specific configuration. - * Import this file in main.ts before mounting the Vue app. + * Initialize from App.vue before mounting business views. */ import { configStore, ExtensionRegistryOriginResolver, + DEFAULT_EXTENSION_REGISTRY_ORIGIN, localStorageAdapter, registerCoreResolvers, TextResolver, @@ -22,6 +23,7 @@ import { PeerManager, WebPeerRuntime, JobManager, + Peer, type ExtensionModule, type ExtensionSetupContribution, } from '@inkcre/core' @@ -48,6 +50,59 @@ import ContentAudio from '@/components/info-base/resolvers/ContentAudio.vue' import ContentFile from '@/components/info-base/resolvers/ContentFile.vue' import ContentPreview from '@/components/info-base/resolvers/ContentPreview.vue' +type NavigatorWithUserAgentData = Navigator & { + userAgentData?: { platform?: string } +} + +export function defaultWebPeerName(browser: NavigatorWithUserAgentData = navigator): string { + const agent = browser.userAgent + const browsers: Array<[string, RegExp]> = [ + ['Edge', /Edg\/(\d+)/], + ['Firefox', /Firefox\/(\d+)/], + ['Chrome', /(?:Chrome|CriOS)\/(\d+)/], + ['Safari', /Version\/(\d+).*Safari/], + ] + const matchedBrowser = browsers.find(([, pattern]) => pattern.test(agent)) ?? null + const browserLabel = matchedBrowser + ? `${matchedBrowser[0]} ${agent.match(matchedBrowser[1])?.[1]}` + : 'Web Peer' + const platform = browser.userAgentData?.platform || browser.platform || agent + const systems: Array<[string, RegExp]> = [ + ['Windows', /Win/], + ['macOS', /Mac/], + ['Android', /Android/], + ['iOS', /iOS|iPhone|iPad|iPod/], + ['Linux', /Linux/], + ] + const system = systems.find(([, pattern]) => pattern.test(platform))?.[0] + return system ? `${browserLabel} · ${system}` : browserLabel +} + +export const WEB_PEER_IDENTITY = { + applicationVersion: packageJson.version, + defaultName: defaultWebPeerName(), +} + +// Runtime identity for this browser; never persisted as a second copy of the Peer name. +export const currentWebPeer = Vue.shallowRef | null>(null) + +export async function refreshCurrentWebPeer(): Promise { + const peerId = configStore.metaConfig.INKCRE_PEER_ID + const origin = configStore.metaConfig.INKCRE_PGREST_URL + currentWebPeer.value = null + try { + const peer = await Peer.getSelf() + if ( + peerId === configStore.metaConfig.INKCRE_PEER_ID && + origin === configStore.metaConfig.INKCRE_PGREST_URL + ) { + currentWebPeer.value = { id: peer.id, name: peer.name } + } + } catch { + // A display name is best-effort; connection failure remains owned by the connection UI. + } +} + // ============================================================================ // Resolver Component Registration // ============================================================================ @@ -87,20 +142,23 @@ type ClientExtensionManager = ExtensionManager let extensionHost: ClientExtensionManager | null = null let extensionHostStartup: Promise | null = null +let extensionRegistry: RegistryReleaseReader | null = null +let extensionRegistryOrigin: ExtensionRegistryOriginResolver | null = null let moduleFederation: ReturnType | null = null let webPeerRuntime: WebPeerRuntime | null = null export function initializeExtensionHost(): ClientExtensionManager { extensionHostStartup = null - const registryOrigin = new ExtensionRegistryOriginResolver( + extensionRegistryOrigin = new ExtensionRegistryOriginResolver( () => configStore.peerConfig.extension_registry_url ) if (!moduleFederation) throw new Error('Module Federation has not been initialized.') + extensionRegistry = new RegistryReleaseReader({ + registryOrigin: getExtensionRegistryOrigin, + hostSdk: { name: '@inkcre/core', version: corePackageJson.version }, + }) extensionHost = new ExtensionManager({ - releases: new RegistryReleaseReader({ - registryOrigin: () => registryOrigin.resolve(), - hostSdk: { name: '@inkcre/core', version: corePackageJson.version }, - }), + releases: extensionRegistry, moduleFederation, }) return extensionHost @@ -124,6 +182,23 @@ export function getExtensionHost(): ClientExtensionManager { return extensionHost } +export function getExtensionRegistry(): RegistryReleaseReader { + if (!extensionRegistry) { + throw new Error('Extension Registry reader has not been initialized.') + } + return extensionRegistry +} + +export function getExtensionRegistryOrigin(): Promise { + if (!configStore.metaConfig.INKCRE_PGREST_URL) { + return Promise.resolve(DEFAULT_EXTENSION_REGISTRY_ORIGIN) + } + if (!extensionRegistryOrigin) { + throw new Error('Extension Registry origin has not been initialized.') + } + return extensionRegistryOrigin.resolve() +} + /** Project the running native module into the Client-owned setup popup contract. */ export function getExtensionSetupContribution(name: string): ExtensionSetupContribution | null { return getExtensionHost().getModule(name)?.setup ?? null @@ -139,16 +214,20 @@ export function adoptWebPeerRuntime(runtime: WebPeerRuntime): void { export async function stopWebPeerRuntime(): Promise { webPeerRuntime?.stop() webPeerRuntime = null + currentWebPeer.value = null await JobManager.stopWorker() } /** Start the lease after Settings has mounted and loaded recovery configuration. */ export async function startConfiguredWebPeerRuntime(): Promise { if (!configStore.metaConfig.INKCRE_PGREST_URL || !configStore.metaConfig.INKCRE_JWT_SECRET) return - const candidate = new WebPeerRuntime(configStore.metaConfig.INKCRE_PEER_ID) + const candidate = new WebPeerRuntime(configStore.metaConfig.INKCRE_PEER_ID, WEB_PEER_IDENTITY) try { + const peer = await candidate.register() + await configStore.loadPeerConfig() await candidate.start() adoptWebPeerRuntime(candidate) + currentWebPeer.value = { id: peer.id, name: peer.name } } catch (error) { candidate.stop() throw error @@ -236,7 +315,7 @@ export function initializeModuleFederation(): void { /** * Initialize all core systems. - * Call this in main.ts before creating the Vue app. + * Call this from App.vue before mounting business views. */ export function shouldLoadPeerConfigAtBootstrap(pathname: string): boolean { return !/^\/settings(?:\/|$)/.test(pathname) @@ -251,12 +330,13 @@ export async function initializeCore(options: { loadPeerConfig?: boolean } = {}) configStore.metaConfig.INKCRE_PGREST_URL && configStore.metaConfig.INKCRE_JWT_SECRET ) { - const candidate = new WebPeerRuntime(configStore.metaConfig.INKCRE_PEER_ID) + const candidate = new WebPeerRuntime(configStore.metaConfig.INKCRE_PEER_ID, WEB_PEER_IDENTITY) try { - await candidate.register() + const peer = await candidate.register() await configStore.loadPeerConfig() await candidate.start() adoptWebPeerRuntime(candidate) + currentWebPeer.value = { id: peer.id, name: peer.name } } catch (error) { candidate.stop() throw error diff --git a/apps/client-web/src/extension-peer-control.ts b/apps/client-web/src/extension-peer-control.ts index 8a96aabd..a0c79a26 100644 --- a/apps/client-web/src/extension-peer-control.ts +++ b/apps/client-web/src/extension-peer-control.ts @@ -1,10 +1,4 @@ -import { - InstallExtensionInputSchema, - ExtensionModel, - Peer, - type InstalledExtension, - type InstallExtensionInput, -} from '@inkcre/core' +import { ExtensionModel, Peer, type InstalledExtension } from '@inkcre/core' import { EXTENSION_MANAGEMENT_CAPABILITY, manageExtensionOnPeer, @@ -24,11 +18,11 @@ export function peerAdvertises(peer: Peer, capability: string): boolean { export function extensionPeerControlMode( peer: Peer, - currentPeerId: string + currentPeerId: string, + livePeerIds: ReadonlySet ): ExtensionPeerControlMode { if (peer.id === currentPeerId) return 'current-runtime' - const live = peer.lease_expires_at !== null && peer.lease_expires_at.getTime() > Date.now() - return live && peerAdvertises(peer, EXTENSION_MANAGEMENT_CAPABILITY) + return livePeerIds.has(peer.id) && peerAdvertises(peer, EXTENSION_MANAGEMENT_CAPABILITY) ? 'remote-host' : 'desired-state' } @@ -37,10 +31,11 @@ export async function setExtensionPeerEnabled(input: { name: string peer: Peer currentPeerId: string + livePeerIds: ReadonlySet enabled: boolean manager: ExtensionManager }): Promise { - const mode = extensionPeerControlMode(input.peer, input.currentPeerId) + const mode = extensionPeerControlMode(input.peer, input.currentPeerId, input.livePeerIds) if (mode === 'current-runtime') { return input.enabled ? input.manager.enable(input.name, input.currentPeerId) @@ -59,38 +54,3 @@ export async function setExtensionPeerEnabled(input: { extension: input.name, }) } - -/** Validate with the selected Host; a Python-only Release need not run in the browser. */ -export async function installExtensionForPeer(input: { - coordinate: InstallExtensionInput - peer: Peer - currentPeerId: string - manager: ExtensionManager - operation: 'install' | 'change-version' -}): Promise { - const coordinate = InstallExtensionInputSchema.parse(input.coordinate) - const mode = extensionPeerControlMode(input.peer, input.currentPeerId) - if (mode === 'current-runtime') { - return input.operation === 'install' - ? input.manager.install(coordinate) - : input.manager.changeVersion(coordinate.name, coordinate.version) - } - if (mode !== 'remote-host') { - throw new Error( - 'Installation requires the selected Client to have a live Extension management endpoint.' - ) - } - if (input.operation === 'install') { - const existing = await ExtensionModel.get(coordinate.name) - if (existing && existing.version !== coordinate.version) { - throw new Error( - `${coordinate.name} is already installed at ${existing.version}. Use Change Version after disabling every Peer.` - ) - } - } - return manageExtensionOnPeer(input.peer.id, { - action: 'install', - extension: coordinate.name, - version: coordinate.version, - }) -} diff --git a/apps/client-web/src/locales/index.ts b/apps/client-web/src/locales/index.ts index 78c49315..77c91727 100644 --- a/apps/client-web/src/locales/index.ts +++ b/apps/client-web/src/locales/index.ts @@ -1,5 +1,7 @@ import { createI18n } from 'vue-i18n' import { locales as uiLocales } from '@inkcre/ui-web/locales' +import en from './messages/en.json' +import zhCN from './messages/zh-CN.json' export const SUPPORT_LOCALES = ['en', 'zh-CN'] as const export type SupportLocale = (typeof SUPPORT_LOCALES)[number] @@ -37,56 +39,24 @@ function getInitialLocale(): SupportLocale { return 'en' } -// Create i18n instance with lazy loading +const initialLocale = getInitialLocale() +// Both small catalogs ship with the app so its first render never exposes translation keys. const i18n = createI18n({ legacy: false, // Use Composition API - locale: getInitialLocale(), + locale: initialLocale, fallbackLocale: 'en', - messages: {}, // Start with empty messages, load lazily + messages: { + en: { ...en, ui: uiLocales.en }, + 'zh-CN': { ...zhCN, ui: uiLocales['zh-CN'] }, + }, }) -// Cache loaded locales -const loadedLanguages: Set = new Set() - -// Function to load locale messages -export async function loadLocaleMessages(locale: SupportLocale) { - // Return if already loaded - if (loadedLanguages.has(locale)) { - return - } - - // Load the locale messages - try { - const messages = await import(`./messages/${locale}.json`) - i18n.global.setLocaleMessage(locale, { ...messages.default, ui: uiLocales[locale] }) - loadedLanguages.add(locale) - } catch (error) { - console.error( - `Failed to load locale "${locale}". Falling back to already loaded locales.`, - 'Available locales:', - Array.from(loadedLanguages), - 'Error:', - error - ) - } -} - -// Function to change locale -export async function setLocale(locale: SupportLocale) { - // Load locale messages if not loaded - await loadLocaleMessages(locale) - - // Set the locale +export function setLocale(locale: SupportLocale) { i18n.global.locale.value = locale - - // Save to localStorage localStorage.setItem(STORAGE_KEY, locale) - - // Update document lang attribute - document.querySelector('html')?.setAttribute('lang', locale) + document.documentElement.lang = locale } -// Load initial locale on startup -void loadLocaleMessages(getInitialLocale()) +document.documentElement.lang = initialLocale export default i18n diff --git a/apps/client-web/src/locales/messages/en.json b/apps/client-web/src/locales/messages/en.json index fab83d1d..c410e687 100644 --- a/apps/client-web/src/locales/messages/en.json +++ b/apps/client-web/src/locales/messages/en.json @@ -1,5 +1,7 @@ { "common": { + "details": "Details", + "errorDetails": "Error details", "settings": "Settings", "language": "Language", "save": "Save", @@ -12,31 +14,52 @@ "saveFailed": "Unable to save. Please try again.", "invalidConfig": "Enter a valid JSON configuration object before saving.", "close": "Close", - "refresh": "Refresh" + "refresh": "Refresh", + "retry": "Retry", + "visual": "Form", + "json": "JSON", + "fixJsonBeforeForm": "Fix the JSON before switching to the form." + }, + "startup": { + "loading": "Starting InKCre…", + "failed": "Unable to start InKCre. Retry or check your connection in Settings." }, "header": { "brand": "InKCre" }, + "navigation": { + "source": "Source", + "job": "Job", + "block": "Block", + "relation": "Relation", + "content": "Content", + "graph": "Graph" + }, + "recall": { + "title": "Recall information", + "mode": "Search mode", + "recall": "Recall", + "findPath": "Find path", + "pathPlaceholder": "Find path endpoints", + "placeholder": "Recall a clue", + "chooseDestination": "Choose the destination Block." + }, "sidePanel": { "managing": "Managing", "sources": "Sources", "extensions": "Extensions", + "peers": "Peers", "settings": "Settings", "explore": "Explore", "infoBase": "Info Base" }, "settings": { + "backup": "Backup and restore", + "importSuccess": "Configuration restored.", "title": "Settings", - "metaConfig": "Meta Configuration", - "coreUrl": "Core API URL", + "metaConfig": "Connection", "pgrestUrl": "PostgreSQL REST URL", - "extensionRegistryUrl": "Extension Registry URL", "jwtSecret": "JWT Secret", - "clientId": "Client ID", - "peerId": "Client ID", - "peerIdGenerated": "Generated and persisted for this browser origin.", - "peerConfig": "Client Settings", - "peerConfigNotice": "Saved for this client and shared with every browser session using this Client ID.", "languageLabel": "Language", "languageDescription": "Select your preferred language", "saveConfig": "Save", @@ -46,11 +69,7 @@ "resetConfirmMessage": "Are you sure you want to reset all settings to default? This action cannot be undone.", "exportConfig": "Export", "importConfig": "Import", - "importError": "Failed to import configuration. Please check the file format.", - "storageNotice": "This configuration belongs to this browser origin and is restored automatically.", - "jwtStoredLocally": "Stored in this browser only. It is never supplied by the build or hosting platform.", - "exportExcludesSecret": "Exports exclude the JWT secret. Importing an export preserves the secret already stored in this browser.", - "language": "Language" + "importError": "Unable to restore configuration. Check the file and deployment connection." }, "source": { "deleteConfirmTitle": "Delete Source", @@ -110,6 +129,13 @@ "typeUnavailable": "This source type is unavailable. Check installed extensions or choose an available type." }, "extension": { + "versionAndRemoval": "Version and removal", + "versionsLoading": "Loading versions…", + "documentationMissing": "No documentation published for this release.", + "desiredStateExplanation": "The selected Peers will apply this change when they next sync.", + "title": "Extensions", + "installed": "Installed", + "browseRegistry": "Browse Registry", "documentation": "Documentation", "documentationScope": { "global": "Extension guide", @@ -117,12 +143,19 @@ "module-federation": "Web guide" }, "documentationUnavailable": "Documentation is temporarily unavailable.", - "installNew": "Install Extension", - "installExtensionTitle": "Install New Extension", - "name": "Extension Name", - "namePlaceholder": "namespace/name, for example inkcre/twitter", + "registryUnavailable": "The Registry is unavailable", + "releaseUnavailable": "Unable to load this Extension", + "invalidInstallLink": "This installation link needs one Extension ID and one exact version.", + "connectBeforeInstall": "Connect your deployment in", + "connectDeployment": "Connect your deployment", + "connectDeploymentDescription": "Add its connection in Settings to manage Extensions here.", + "installScope": "After installing, enable the Extension on a Peer.", + "hostCore": "Core", + "hostWeb": "Web", + "install": "Install", + "installedVersion": "Installed v{version}", + "manage": "Manage", "version": "Version", - "versionPlaceholder": "exact version, for example 0.1.0", "editConfig": "Edit Config", "editConfigTitle": "Edit Extension Configuration", "changeVersion": "Change Version", @@ -130,18 +163,24 @@ "uninstall": "Uninstall", "setup": "Setup", "setupTitle": "Set up {name}", - "peerSelector": "Control Extension on Client", - "peerSelectorPlaceholder": "Select a Client", - "installOnSelectedClient": "The selected Client validates installation and version changes. Installation is shared across the instance; enable the Extension separately on each Client that should run it.", - "installRequiresLiveClient": "Select an online Client with Extension management to install or change a version.", - "peerNotFound": "The selected Client no longer exists.", + "enableOnPeers": "Enable…", + "disableOnPeers": "Disable…", + "enablePeerTitle": "Enable {name} on Peers", + "disablePeerTitle": "Disable {name} on Peers", + "enableSelected": "Enable selected", + "disableSelected": "Disable selected", "currentBrowser": "This browser", - "desiredStateOnly": "This Client has no live management endpoint. Changes update its durable enabled state and take effect when that Client next reconciles it.", - "peerListUnavailable": "The Client list could not be loaded: {error}", + "desiredStateBrief": "Waiting for Peer sync", + "peerListUnavailable": "The Peer list could not be loaded: {error}", + "installedUnavailable": "Unable to load installed Extensions", + "noneInstalled": "No Extensions installed", + "noneInstalledDescription": "Browse the Registry to add an Extension.", "enabledPeerCount": "Enabled on {count} Peer(s).", "uninstallDisabled": "Disable every Peer before uninstalling or changing the shared version." }, "job": { + "loadFailed": "Unable to load this job.", + "sourceLoadFailed": "Unable to load the source.", "title": "Job Details", "jobId": "Job ID", "status": "Status", @@ -160,26 +199,35 @@ "sourceNotFound": "Source not found" }, "logs": { + "loading": "Loading logs…", + "autoUpdating": "Updating automatically", + "loadFailed": "Unable to load logs.", "title": "Logs", "empty": "No logs" }, - "client": { - "listTitle": "Clients", + "peer": { + "title": "Peers", "refresh": "Refresh", - "checkHealth": "Check Health", "name": "Name", "id": "ID", - "restUrl": "REST API URL", - "createdAt": "Created At", + "current": "This browser", + "versionUnknown": "Version unknown", + "capabilities": "{count} capabilities", "statusOnline": "Online", "statusOffline": "Offline", "statusUnknown": "Unknown", - "noClients": "No clients registered", - "editConfig": "Edit Config" + "empty": "No Peers registered", + "listFailed": "Unable to load Peers", + "editConfig": "Edit Config", + "delete": "Delete Peer", + "deleteConfirm": "This removes the Peer and its extension enablement from this deployment. A running Peer may register again when it reconnects.", + "deleteOnline": "Stop this Peer and wait for it to go offline before deleting it.", + "deleteFailed": "Unable to delete this Peer. Please try again." }, "infoBase": { + "relationLoadFailed": "Unable to load this Relation.", "list": { - "eyebrow": "Lexical recall", + "help": "Search help", "title": "Find the information you remember.", "searchLabel": "Search the info base", "searchPlaceholder": "A phrase, name, filename, or remembered detail…", @@ -196,6 +244,7 @@ } }, "blockInspector": { + "loadFailed": "Unable to load this Block.", "title": "Block Inspector", "id": "ID", "resolver": "Resolver", diff --git a/apps/client-web/src/locales/messages/zh-CN.json b/apps/client-web/src/locales/messages/zh-CN.json index 98dcfb52..7edc012e 100644 --- a/apps/client-web/src/locales/messages/zh-CN.json +++ b/apps/client-web/src/locales/messages/zh-CN.json @@ -1,5 +1,7 @@ { "common": { + "details": "详情", + "errorDetails": "错误详情", "settings": "设置", "language": "语言", "save": "保存", @@ -12,31 +14,52 @@ "saveFailed": "保存失败,请稍后重试。", "invalidConfig": "请输入有效的 JSON 配置对象后再保存。", "close": "关闭", - "refresh": "刷新" + "refresh": "刷新", + "retry": "重试", + "visual": "表单", + "json": "JSON", + "fixJsonBeforeForm": "先修正 JSON,才能切换到表单。" + }, + "startup": { + "loading": "正在启动 InKCre…", + "failed": "无法启动 InKCre。请重试,或前往设置检查连接。" }, "header": { - "brand": "第三尺寸" + "brand": "InKCre" + }, + "navigation": { + "source": "数据源", + "job": "任务", + "block": "区块", + "relation": "关系", + "content": "内容", + "graph": "信息图" + }, + "recall": { + "title": "查找信息", + "mode": "搜索模式", + "recall": "检索", + "findPath": "查找路径", + "pathPlaceholder": "查找路径端点", + "placeholder": "输入记得的线索", + "chooseDestination": "请选择目标 Block。" }, "sidePanel": { "managing": "管理", "sources": "数据源", "extensions": "扩展", + "peers": "Peer", "settings": "设置", "explore": "探索", "infoBase": "信息库" }, "settings": { + "backup": "备份与恢复", + "importSuccess": "配置已恢复。", "title": "设置", - "metaConfig": "运行配置", - "coreUrl": "核心 API 地址", + "metaConfig": "连接", "pgrestUrl": "PostgreSQL REST 地址", - "extensionRegistryUrl": "扩展注册表地址", "jwtSecret": "JWT 密钥", - "clientId": "客户端 ID", - "peerId": "客户端 ID", - "peerIdGenerated": "由当前浏览器来源自动生成并持久保存。", - "peerConfig": "客户端设置", - "peerConfigNotice": "配置存储在当前客户端上,并由使用同一客户端 ID 的浏览器会话共享。", "languageLabel": "语言", "languageDescription": "选择您的首选语言", "saveConfig": "保存", @@ -46,11 +69,7 @@ "resetConfirmMessage": "确定要重置所有设置为默认值吗?此操作无法撤销。", "exportConfig": "导出", "importConfig": "导入", - "importError": "导入配置失败,请检查文件格式。", - "storageNotice": "配置属于当前浏览器来源,并会在重新加载时自动恢复。", - "jwtStoredLocally": "仅存储在当前浏览器中,不由构建产物或托管平台提供。", - "exportExcludesSecret": "导出文件不包含 JWT 密钥;导入时会保留当前浏览器已存储的密钥。", - "language": "语言" + "importError": "无法恢复配置,请检查文件及部署连接。" }, "source": { "deleteConfirmTitle": "删除数据源", @@ -111,6 +130,13 @@ "typeUnavailable": "当前数据源类型不可用,请检查已安装的扩展或选择可用类型。" }, "extension": { + "versionAndRemoval": "版本与卸载", + "versionsLoading": "正在读取版本…", + "documentationMissing": "此版本尚未发布文档。", + "desiredStateExplanation": "所选 Peer 将在下次同步时应用此修改。", + "title": "扩展", + "installed": "已安装", + "browseRegistry": "浏览 Registry", "documentation": "扩展文档", "documentationScope": { "global": "扩展指南", @@ -118,12 +144,19 @@ "module-federation": "Web 指南" }, "documentationUnavailable": "文档暂时不可用。", - "installNew": "安装扩展", - "installExtensionTitle": "安装新扩展", - "name": "扩展名称", - "namePlaceholder": "namespace/name,例如 inkcre/twitter", + "registryUnavailable": "Registry 暂时不可用", + "releaseUnavailable": "无法读取此扩展", + "invalidInstallLink": "安装链接需要包含一个扩展 ID 和一个精确版本。", + "connectBeforeInstall": "请先在此处连接自己的部署:", + "connectDeployment": "连接自己的部署", + "connectDeploymentDescription": "在设置中填入连接信息,即可在此管理扩展。", + "installScope": "安装到当前部署后,可以再选择 Peer 启用该扩展。", + "hostCore": "Core", + "hostWeb": "Web", + "install": "安装", + "installedVersion": "已安装 v{version}", + "manage": "管理", "version": "版本", - "versionPlaceholder": "精确版本,例如 0.1.0", "editConfig": "编辑配置", "editConfigTitle": "编辑扩展配置", "changeVersion": "变更版本", @@ -131,18 +164,24 @@ "uninstall": "卸载", "setup": "设置", "setupTitle": "设置 {name}", - "peerSelector": "控制扩展的客户端", - "peerSelectorPlaceholder": "选择客户端", - "installOnSelectedClient": "所选客户端负责安装与版本变更的兼容性校验。安装记录由整个实例共享;请在需要运行扩展的客户端上分别启用。", - "installRequiresLiveClient": "安装或变更版本需要选择在线且支持扩展管理的客户端。", - "peerNotFound": "所选客户端已不存在。", + "enableOnPeers": "启用…", + "disableOnPeers": "停用…", + "enablePeerTitle": "选择要启用 {name} 的 Peer", + "disablePeerTitle": "选择要停用 {name} 的 Peer", + "enableSelected": "在所选 Peer 上启用", + "disableSelected": "在所选 Peer 上停用", "currentBrowser": "当前浏览器", - "desiredStateOnly": "此客户端没有在线管理端点;操作只更新其持久启用状态,并在该客户端下次协调状态时生效。", - "peerListUnavailable": "无法加载客户端列表:{error}", + "desiredStateBrief": "等待 Peer 同步", + "peerListUnavailable": "无法加载 Peer 列表:{error}", + "installedUnavailable": "无法读取已安装扩展", + "noneInstalled": "还没有安装扩展", + "noneInstalledDescription": "浏览 Registry,选择要安装的扩展。", "enabledPeerCount": "已在 {count} 个 Peer 上启用。", "uninstallDisabled": "卸载或变更共享版本前,必须先禁用所有 Peer。" }, "job": { + "loadFailed": "无法读取此任务。", + "sourceLoadFailed": "无法读取来源。", "title": "任务详情", "jobId": "任务 ID", "status": "状态", @@ -161,25 +200,35 @@ "sourceNotFound": "未找到数据源" }, "logs": { + "loading": "正在读取日志…", + "autoUpdating": "自动更新中", + "loadFailed": "无法读取日志。", "title": "日志", "empty": "暂无日志" }, - "client": { - "listTitle": "客户端", + "peer": { + "title": "Peer", "refresh": "刷新", - "checkHealth": "检查状态", "name": "名称", "id": "ID", - "restUrl": "REST API 地址", - "createdAt": "创建时间", + "current": "当前浏览器", + "versionUnknown": "版本未知", + "capabilities": "{count} 项能力", "statusOnline": "在线", "statusOffline": "离线", "statusUnknown": "未知", - "noClients": "暂无注册客户端" + "empty": "暂无已注册的 Peer", + "listFailed": "无法加载 Peer", + "editConfig": "编辑配置", + "delete": "删除 Peer", + "deleteConfirm": "这会从当前部署中移除该 Peer 及其扩展启用记录。若它重新连接,可能会再次注册。", + "deleteOnline": "请先停止该 Peer,待它离线后再删除。", + "deleteFailed": "无法删除该 Peer,请重试。" }, "infoBase": { + "relationLoadFailed": "无法读取此关系。", "list": { - "eyebrow": "文本特征回忆", + "help": "搜索说明", "title": "找回你记得的信息。", "searchLabel": "检索信息库", "searchPlaceholder": "一句话、名称、文件名,或仍有印象的细节……", @@ -196,6 +245,7 @@ } }, "blockInspector": { + "loadFailed": "无法读取此区块。", "title": "区块检查器", "id": "ID", "resolver": "解析器", diff --git a/apps/client-web/src/main.ts b/apps/client-web/src/main.ts index a06fd12c..484719b1 100644 --- a/apps/client-web/src/main.ts +++ b/apps/client-web/src/main.ts @@ -22,29 +22,4 @@ app.use(store) app.use(router) setInfoBaseRouter(createInfoBaseRouterAdapter(router)) -// Initialize core package -import { initializeCore, shutdownCore } from './core' -import { shouldLoadPeerConfigAtBootstrap } from './core' -const loadPeerConfigAtBootstrap = shouldLoadPeerConfigAtBootstrap(window.location.pathname) -await initializeCore({ - // Settings is the recovery surface for invalid or incomplete bootstrap - // credentials. It mounts before contacting the configured Peer. - loadPeerConfig: loadPeerConfigAtBootstrap, -}) app.mount('#app') - -import { getExtensionHost, startExtensionHost } from './core' -const extensionHost = getExtensionHost() -if (loadPeerConfigAtBootstrap) { - startExtensionHost().catch((error) => { - console.error('[Web Extension Host] Startup failed:', error) - }) -} - -window.addEventListener('beforeunload', () => { - // Browsers may end the page before cleanup completes, but never deliberately - // dispose Extension resources while our Job handlers are still draining. - void shutdownCore() - .then(() => extensionHost.shutdown()) - .catch((error: unknown) => console.error('[Core] Shutdown failed', error)) -}) diff --git a/apps/client-web/src/router.ts b/apps/client-web/src/router.ts index e65ecb49..b41dc221 100644 --- a/apps/client-web/src/router.ts +++ b/apps/client-web/src/router.ts @@ -9,6 +9,7 @@ import job from '@/views/jobs/job/job.vue' import extensions from '@/views/extensions/extensions.vue' import infoBaseGraph from '@/views/info-base/graph/graph.vue' import settings from '@/views/settings/settings.vue' +import peers from '@/views/peers/peers.vue' import { computed } from 'vue' const router = createRouter({ @@ -42,50 +43,61 @@ const router = createRouter({ path: '/sources', name: 'Sources', component: sources, + meta: { titleKey: 'sidePanel.sources' }, }, { path: '/jobs/:id', name: 'Job', component: job, + meta: { titleKey: 'job.title' }, }, { path: '/sources/:id', name: 'Source', component: source, + meta: { titleKey: 'sidePanel.sources' }, }, { path: '/extensions', name: 'Extensions', component: extensions, + meta: { titleKey: 'sidePanel.extensions' }, }, { path: '/info-base/graph', name: 'InfoBaseGraphOverview', component: infoBaseGraph, - meta: { title: 'Info Base' }, + meta: { titleKey: 'navigation.graph' }, }, { path: '/info-base/graph/blocks/:block/content', name: 'InfoBaseGraphSolvedContent', component: infoBaseGraph, - meta: { title: 'Info Base' }, + meta: { titleKey: 'navigation.graph' }, }, { path: '/info-base/graph/blocks/:block', name: 'InfoBaseGraphBlock', component: infoBaseGraph, - meta: { title: 'Info Base' }, + meta: { titleKey: 'navigation.graph' }, }, { path: '/info-base/graph/relations/:relation', name: 'InfoBaseGraphRelation', component: infoBaseGraph, - meta: { title: 'Info Base' }, + meta: { titleKey: 'navigation.graph' }, + }, + { + path: '/peers', + name: 'Peers', + component: peers, + meta: { titleKey: 'sidePanel.peers' }, }, { path: '/settings', name: 'Settings', component: settings, + meta: { titleKey: 'sidePanel.settings' }, }, ], }) diff --git a/apps/client-web/src/views/extensions/extensions.scss b/apps/client-web/src/views/extensions/extensions.scss index b76ce278..ea6464d2 100644 --- a/apps/client-web/src/views/extensions/extensions.scss +++ b/apps/client-web/src/views/extensions/extensions.scss @@ -1,40 +1,96 @@ .extensions-view { display: flex; - flex-wrap: wrap; - align-content: flex-start; - align-items: flex-start; - min-width: 0; - overflow-y: auto; - gap: sys-var(space, lg); flex: 1; + flex-direction: column; align-self: stretch; + gap: sys-var(space, lg); + min-width: 0; + padding: clamp(1rem, 4vw, 2rem); + overflow-y: auto; box-sizing: border-box; - padding: clamp(16px, 4vw, 32px); background: sys-var(color, surface, base); + &__page-header { + display: flex; + flex-wrap: wrap; + align-items: end; + justify-content: space-between; + gap: sys-var(space, md); + } + + &__browse-link { + white-space: nowrap; + } + + &__install { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: sys-var(space, sm); + max-width: 42rem; + padding: sys-var(space, lg); + border: 1px solid sys-var(color, border, base); + + h2, + p { + margin: 0; + } + + h2 { + @include apply-font(title-sm); + } + } + + &__install-coordinate, + &__install-hosts { + @include apply-font(body-sm); + color: sys-var(color, text, subtle); + } + + &__install-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: sys-var(space, md); + margin-top: sys-var(space, sm); + } + + &__peer-status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: sys-var(space, sm); + } + &__list { display: flex; flex-direction: column; gap: sys-var(space, md); - flex: 1 1 24rem; - min-height: 120px; + min-height: 10rem; min-width: 0; } - &__header { - flex-basis: 100%; + &__skeleton { display: grid; - gap: sys-var(space, sm); + gap: sys-var(space, md); + width: 100%; + box-sizing: border-box; + padding: sys-var(space, lg) sys-var(space, md); + margin-bottom: sys-var(space, md); + border: 1px solid sys-var(color, border, subtle); } - &__notice { + &__notice, + &__error { @include apply-font(body-sm); margin: 0; + } + + &__notice { color: sys-var(color, text, subtle); } &__error { - @include apply-font(body-sm); color: sys-var(color, feedback, error); } } diff --git a/apps/client-web/src/views/extensions/extensions.vue b/apps/client-web/src/views/extensions/extensions.vue index cd96d00e..87b7a272 100644 --- a/apps/client-web/src/views/extensions/extensions.vue +++ b/apps/client-web/src/views/extensions/extensions.vue @@ -1,37 +1,58 @@ diff --git a/apps/client-web/src/views/info-base/list/list.scss b/apps/client-web/src/views/info-base/list/list.scss index 4452ba2c..b268a544 100644 --- a/apps/client-web/src/views/info-base/list/list.scss +++ b/apps/client-web/src/views/info-base/list/list.scss @@ -12,12 +12,27 @@ margin-inline: auto; } - &__eyebrow { - @include apply-font(label-md); + &__help, + &__match-details { + @include apply-font(body-sm); color: sys-var(color, text, subtle); - margin: 0 0 sys-var(space, sm); - letter-spacing: 0.08em; - text-transform: uppercase; + margin-top: sys-var(space, sm); + summary { + cursor: pointer; + } + p { + margin: sys-var(space, sm) 0; + } + } + &__skeletons { + display: grid; + gap: sys-var(space, md); + } + &__skeleton { + display: grid; + gap: sys-var(space, md); + padding: sys-var(space, lg) sys-var(space, md); + border: 1px solid sys-var(color, border, subtle); } h1 { diff --git a/apps/client-web/src/views/info-base/list/list.vue b/apps/client-web/src/views/info-base/list/list.vue index 7b21f426..9d724451 100644 --- a/apps/client-web/src/views/info-base/list/list.vue +++ b/apps/client-web/src/views/info-base/list/list.vue @@ -2,7 +2,7 @@ import { computed, ref, shallowRef, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useRoute } from 'vue-router' -import { InkButton, InkLoading } from '@inkcre/ui-web' +import { InkButton, InkSkeleton } from '@inkcre/ui-web' import { getInfoBaseRouter, LexicalRetrievalManager, @@ -81,7 +81,6 @@ function refresh(): void {