feat: 通过加密会话和冲突设置连接桌面 Sync

This commit is contained in:
2026-09-08 16:03:49 +08:00
parent 3a4d6e5586
commit 91ef49442d
27 changed files with 1005 additions and 9 deletions
@@ -12,6 +12,7 @@ import ProviderLogo from './ProviderLogo.vue'
import ModelRoutingSettings from './ModelRoutingSettings.vue'
import LocalModelSettings from './LocalModelSettings.vue'
import UsageCard from './UsageCard.vue'
import SyncSettings from './SyncSettings.vue'
import CredentialVaultSettings from './CredentialVaultSettings.vue'
import { isDesktop } from '@/services/platform/desktop'
import { useProviderStore } from '@/stores/provider'
@@ -19,9 +20,10 @@ import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
import { t } from '@/i18n'
type Section = 'general' | 'editor' | 'providers' | 'index' | 'permissions' | 'ai-core'
type Section = 'general' | 'editor' | 'providers' | 'index' | 'permissions' | 'ai-core' | 'sync'
const sections = computed<Array<{ id: Section; label: string }>>(() => [
{ id: 'general', label: t('通用', 'General') }, { id: 'editor', label: t('编辑器', 'Editor') }, { id: 'providers', label: t('模型提供商', 'Model Providers') },
...(isDesktop() ? [{ id: 'sync' as const, label: 'Sync' }] : []),
{ id: 'index', label: t('索引与模型', 'Index and Models') }, { id: 'permissions', label: t('权限', 'Permissions') }, { id: 'ai-core', label: t('AI Core 诊断', 'AI Core Diagnostics') },
])
const activeSection = ref<Section>('general')
@@ -160,6 +162,7 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
<div v-else-if="activeSection === 'permissions'" class="panel settings-section"><h2>{{ t('权限策略', 'Permission Policy') }}</h2><p class="muted section-description">{{ t('以下为后端当前生效的权限策略;全局策略编辑尚未开放,运行时按实际权限请求确认。', 'These policies are active in the backend. Global policy editing is not yet available; runtime requests are confirmed as needed.') }}</p><p v-if="!Object.keys(settingsStore.permissionPolicy).length" class="subtle">{{ t('尚未获取权限策略,请检查后端连接并重新检测。', 'Permission policy is unavailable. Check the backend connection and try again.') }}</p><div class="permission-list"><div v-for="(policy, permission) in settingsStore.permissionPolicy" :key="permission" class="setting-row"><span><strong>{{ permission }}</strong></span><span>{{ policy === 'allow' ? t('允许', 'Allow') : policy === 'confirm' ? t('每次确认', 'Confirm each time') : t('拒绝', 'Deny') }}</span></div></div></div>
<SyncSettings v-else-if="activeSection === 'sync'" />
<div v-else class="panel settings-section"><h2>{{ t('AI Core 诊断', 'AI Core Diagnostics') }}</h2><div v-if="settingsStore.diagnosticsError" class="error-banner">{{ settingsStore.diagnosticsError }}</div><div class="diagnostic-grid"><div class="item-card"><span class="badge" :class="{ success: settingsStore.aiCoreStatus === 'running', error: settingsStore.aiCoreStatus === 'error' }">{{ settingsStore.aiCoreStatus }}</span><h3>{{ t('AI Core 连接状态', 'AI Core connection') }}</h3><p class="subtle">{{ t('AI Core 不可用时,Markdown 编辑仍可继续使用。', 'Markdown editing remains available when AI Core is offline.') }}</p></div><div class="item-card"><strong>{{ settingsStore.aiCoreAddress }}</strong><h3>{{ t('开发 API 地址', 'Development API address') }}</h3><p class="subtle">{{ t('正式桌面环境由 Sidecar Manager 动态提供。', 'The desktop build will provide this through Sidecar Manager.') }}</p></div></div><div class="inline-actions diagnostic-actions"><button class="button-primary" @click="settingsStore.loadDiagnostics">{{ t('重新检测', 'Check again') }}</button><span class="subtle">{{ t('当前 Web 端不支持重启后端进程,请在运行后端的终端中操作。', 'The web build cannot restart the backend. Use the terminal running it.') }}</span></div></div>
<ProviderForm v-if="showProviderForm" :provider="editingProvider" :models="editingProvider ? providerStore.modelsByProvider[editingProvider.provider_id] : []" @close="showProviderForm = false" @saved="providerSaved" />
@@ -0,0 +1,37 @@
// @vitest-environment happy-dom
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, expect, it, vi } from 'vitest'
import { hostInvoke } from '@/services/platform/desktop'
import SyncSettings from './SyncSettings.vue'
vi.mock('@/services/platform/desktop', () => ({ hostInvoke: vi.fn() }))
const confirm = vi.hoisted(() => vi.fn())
vi.mock('@/composables/useActionDialog', () => ({ useActionDialog: () => ({ actionDialog: null, resolveAction: vi.fn(), askConfirm: confirm }) }))
afterEach(() => { vi.useRealTimers(); vi.resetAllMocks() })
const empty = () => ({ vault_id: 'local', binding: null, paused: false, pending: 0, conflicts: [], credential_state: 'unbound', running: false, error: null, retry_in: null })
it('clears login password, explicitly opts into HTTP and stops polling on unmount', async () => {
vi.useFakeTimers()
vi.mocked(hostInvoke).mockImplementation(async command => command === 'sync_status' ? empty() : command === 'sync_login' ? { endpoint: 'http://test.example:18080/', account: 'test' } : { items: [] })
const wrapper = mount(SyncSettings); await flushPromises()
await wrapper.get('input[type=url]').setValue('http://test.example:18080')
await wrapper.get('input[autocomplete=username]').setValue('test')
await wrapper.get('input[type=password]').setValue('private-fixture')
await wrapper.get('input[type=checkbox]').setValue(true)
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(hostInvoke).toHaveBeenCalledWith('sync_login', { request: { endpoint: 'http://test.example:18080', account: 'test', password: 'private-fixture', device_name: 'OpenNexus Desktop', allow_test_http: true } })
expect((wrapper.get('input[type=password]').element as HTMLInputElement).value).toBe('')
expect(wrapper.text()).not.toContain('private-fixture')
wrapper.unmount(); const count = vi.mocked(hostInvoke).mock.calls.length
await vi.advanceTimersByTimeAsync(6000); expect(hostInvoke).toHaveBeenCalledTimes(count)
})
it('cancels destructive choices and binds accepted conflict decisions to their snapshot', async () => {
const state = { ...empty(), binding: { id: 'binding', endpoint: 'https://test.example/', account: 'test', remote_vault: 'remote', cursor: 7 }, credential_state: 'ready', conflicts: [{ sequence: 7, local_path: 'note.md', local_hash: 'original-hash', remote: { path: 'note.md', operation: 'put' } }] }
vi.mocked(hostInvoke).mockResolvedValue(state)
const wrapper = mount(SyncSettings); await flushPromises()
const button = wrapper.findAll('button').find(button => button.text() === '采用远端')!
confirm.mockResolvedValue(false); await button.trigger('click'); await flushPromises()
expect(vi.mocked(hostInvoke).mock.calls.some(([command]) => command === 'sync_resolve')).toBe(false)
confirm.mockResolvedValue(true); await button.trigger('click'); await flushPromises()
expect(hostInvoke).toHaveBeenCalledWith('sync_resolve', { bindingId: 'binding', sequence: 7, choice: 'remote', destination: '', expected: 'original-hash' })
expect(wrapper.get('input[type=url]').attributes('disabled')).toBeDefined()
wrapper.unmount()
})
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { hostInvoke } from '@/services/platform/desktop'
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
import { t } from '@/i18n'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
interface Binding { id: string; endpoint: string; account: string; remote_vault: string; cursor: number }
interface Conflict { sequence: number; local_path: string; local_hash: string; current_hash?: string; current_path?: string; remote: { path: string; operation: string } }
interface Status { vault_id: string; binding: Binding | null; paused: boolean; pending: number; conflicts: Conflict[]; credential_state: string; running: boolean; error: string | null; retry_in: number | null }
interface RemoteVault { id: string; name: string; sequence: number; used: number; quota: number }
const status = ref<Status | null>(null)
const endpoint = ref('https://'), account = ref(''), password = ref(''), device = ref('OpenNexus Desktop'), testHttp = ref(false)
const connected = ref(false), busy = ref(false), message = ref(''), remoteVaults = ref<RemoteVault[]>([]), selected = ref(''), newName = ref('')
const copies = ref<Record<number, string>>({})
let timer: ReturnType<typeof setInterval> | undefined
let mounted = true
async function refresh() {
const next = await hostInvoke<Status>('sync_status')
if (!mounted) return
status.value = next
if (next.binding) { endpoint.value = next.binding.endpoint; account.value = next.binding.account; connected.value = next.credential_state === 'ready' }
}
async function act(action: () => Promise<void>) {
if (busy.value) return
busy.value = true; message.value = ''
try { await action(); await refresh() }
catch (error) { message.value = error instanceof Error ? error.message : 'SYNC_FAILED' }
finally { busy.value = false }
}
async function listVaults() {
const result = await hostInvoke<{ items: RemoteVault[] }>('sync_vaults', { endpoint: endpoint.value, account: account.value })
remoteVaults.value = result.items
}
function login() {
const secret = password.value; password.value = ''
return act(async () => {
const result = await hostInvoke<{ endpoint: string; account: string }>('sync_login', { request: { endpoint: endpoint.value, account: account.value, password: secret, device_name: device.value, allow_test_http: testHttp.value } })
endpoint.value = result.endpoint; account.value = result.account; connected.value = true
await listVaults()
})
}
function createVault() { return act(async () => {
const result = await hostInvoke<{ vault_id: string }>('sync_create_vault', { endpoint: endpoint.value, account: account.value, name: newName.value })
newName.value = ''; await listVaults(); selected.value = result.vault_id
}) }
async function bind(mode: 'upload' | 'download') {
const vaultId = status.value?.vault_id
const remote = selected.value
if (!vaultId || !remote) return
if (!(await askConfirm(mode === 'upload' ? t('将当前本地笔记上传到所选空远端库?', 'Upload current notes to the selected empty remote vault?') : t('将所选远端库下载到当前空本地库?', 'Download the selected remote vault into this empty local vault?')))) return
await act(async () => { await hostInvoke('sync_bind', { request: { vault_id: vaultId, endpoint: endpoint.value, account: account.value, remote_vault: remote, mode } }) })
}
async function unbind() {
const binding = status.value?.binding
if (!binding || !(await askConfirm(t('解除当前绑定并封存待上传任务?本地文件仍保留。', 'Unbind and archive pending uploads? Local files are retained.')))) return
await act(async () => { await hostInvoke('sync_unbind', { bindingId: binding.id }); remoteVaults.value = []; selected.value = '' })
}
async function resolve(conflict: Conflict, choice: 'local' | 'remote' | 'copy') {
const binding = status.value?.binding
const destination = choice === 'copy' ? copies.value[conflict.sequence] ?? '' : ''
if (!binding || (choice === 'copy' && !destination)) return
if (!(await askConfirm(t(`确认解决 ${conflict.local_path} 的冲突?`, `Resolve the conflict for ${conflict.local_path}?`)))) return
await act(async () => { await hostInvoke('sync_resolve', { bindingId: binding.id, sequence: conflict.sequence, choice, destination, expected: conflict.current_hash ?? conflict.local_hash }) })
}
onMounted(() => {
void refresh().catch(error => { message.value = error instanceof Error ? error.message : 'SYNC_FAILED' })
timer = setInterval(() => { if (!busy.value) void refresh().catch(() => {}) }, 1500)
})
onUnmounted(() => { mounted = false; clearInterval(timer); password.value = '' })
</script>
<template>
<section class="panel settings-section sync-settings" aria-labelledby="sync-title">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<h2 id="sync-title">OpenNexus Sync</h2>
<p>{{ t('同步当前 Vault 的文件。登录前请先解锁设备凭据保险库。', 'Sync files in the current vault. Unlock the device credential vault before signing in.') }}</p>
<p class="subtle">{{ t('当前支持本地上传到空远端,或远端下载到空本地。两边都有文件时请先使用独立空库;合并预览仍在开发中。', 'Upload to an empty remote vault, or download into an empty local vault. Initial merge of two populated vaults is still under development.') }}</p>
<p v-if="message" class="error-banner" role="alert">{{ message }}</p>
<form class="sync-form" @submit.prevent="login">
<label>{{ t('服务器地址', 'Server URL') }}<input v-model="endpoint" required :disabled="!!status?.binding || busy" type="url" autocomplete="url" /></label>
<label>{{ t('账户', 'Account') }}<input v-model="account" required :disabled="!!status?.binding || busy" autocomplete="username" /></label>
<label>{{ t('密码', 'Password') }}<input v-model="password" required type="password" autocomplete="current-password" :disabled="busy" /></label>
<label>{{ t('设备名称', 'Device name') }}<input v-model="device" required maxlength="100" :disabled="busy" /></label>
<label class="test-http"><input v-model="testHttp" type="checkbox" :disabled="busy" />{{ t('仅测试:允许 HTTP 明文连接', 'Testing only: allow unencrypted HTTP') }}</label>
<button class="button-primary" :disabled="busy || !password">{{ t('登录', 'Sign in') }}</button>
</form>
<div v-if="connected && !status?.binding" class="sync-connect">
<button :disabled="busy" @click="act(listVaults)">{{ t('刷新远端库', 'Refresh vaults') }}</button>
<label>{{ t('远端库', 'Remote vault') }}<select v-model="selected"><option value="">{{ t('请选择', 'Choose a vault') }}</option><option v-for="vault in remoteVaults" :key="vault.id" :value="vault.id">{{ vault.name }} · {{ vault.sequence }}</option></select></label>
<div class="inline-actions"><input v-model="newName" :placeholder="t('新远端库名称', 'New vault name')" /><button :disabled="busy || !newName.trim()" @click="createVault">{{ t('创建远端库', 'Create vault') }}</button></div>
<div class="inline-actions"><button :disabled="busy || !selected || !status" @click="bind('upload')">{{ t('上传到空远端', 'Upload to empty remote') }}</button><button :disabled="busy || !selected || !status" @click="bind('download')">{{ t('下载到空本地', 'Download into empty local') }}</button></div>
</div>
<div v-if="status?.binding" class="sync-bound">
<p>{{ status.binding.endpoint }} · {{ status.binding.account }} · {{ status.binding.remote_vault }}</p>
<p aria-live="polite">{{ status.paused ? t('已暂停', 'Paused') : status.running ? t('同步中', 'Syncing') : t('等待下一轮同步', 'Waiting for next sync') }} · {{ t('待上传', 'Pending') }} {{ status.pending }} · cursor {{ status.binding.cursor }}</p>
<p v-if="status.credential_state !== 'ready'" role="status">{{ status.credential_state }}</p>
<p v-if="status.error" role="alert">{{ status.error }}<span v-if="status.retry_in"> · {{ status.retry_in }}s</span></p>
<div class="inline-actions">
<button :disabled="busy || status.running || status.paused" @click="act(async () => { await hostInvoke('sync_run') })">{{ t('立即同步', 'Sync now') }}</button>
<button :disabled="busy" @click="act(async () => { await hostInvoke('sync_pause', { bindingId: status!.binding!.id, paused: !status!.paused }) })">{{ status.paused ? t('继续同步', 'Resume sync') : t('暂停同步', 'Pause sync') }}</button>
<button :disabled="busy" @click="unbind">{{ t('解除绑定', 'Unbind') }}</button>
<button :disabled="busy" @click="act(async () => { await hostInvoke('sync_logout', { endpoint, account }); connected = false })">{{ t('退出登录', 'Sign out') }}</button>
</div>
<article v-for="conflict in status.conflicts" :key="conflict.sequence" class="sync-conflict">
<h3>{{ conflict.local_path }}</h3><p>{{ t('远端版本', 'Remote revision') }} {{ conflict.sequence }} · {{ conflict.remote.operation }} · {{ conflict.remote.path }}</p>
<div class="inline-actions"><button :disabled="busy" @click="resolve(conflict, 'local')">{{ t('保留本地', 'Keep local') }}</button><button :disabled="busy" @click="resolve(conflict, 'remote')">{{ t('采用远端', 'Use remote') }}</button></div>
<label>{{ t('副本相对路径', 'Relative copy path') }}<input v-model="copies[conflict.sequence]" placeholder="conflicts/note-copy.md" /></label><button :disabled="busy || !copies[conflict.sequence]" @click="resolve(conflict, 'copy')">{{ t('另存本地副本并采用远端', 'Save local copy and use remote') }}</button>
</article>
</div>
</section>
</template>
<style scoped>
.sync-form,.sync-connect,.sync-bound { display:grid;gap:12px;margin-top:16px }
.sync-form label,.sync-connect label,.sync-conflict label { display:grid;gap:5px }
input,select { padding:8px;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:inherit;min-width:0 }
.test-http { display:flex!important;align-items:center }
.inline-actions { display:flex;flex-wrap:wrap;gap:8px }
.sync-conflict { border:1px solid var(--border-color);padding:14px;border-radius:8px;display:grid;gap:10px }
button { padding:7px 12px;cursor:pointer } button:disabled { cursor:default;opacity:.5 }
</style>