From 3ef4766da4eff24a62798903adfddc2d39eb0991 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Wed, 9 Sep 2026 07:03:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(sync):=20=E6=98=BE=E5=BC=8F=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=20Vault=20=E5=89=8D=E9=A2=84=E8=A7=88=E6=97=A7?= =?UTF-8?q?=E7=89=88=E4=BA=BA=E8=AE=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routes.py | 6 +++ backend/app/services/persona_settings.py | 15 +++++++ backend/tests/test_global_persona.py | 30 +++++++++++++ docs/contracts/Sync-v1契约.md | 5 ++- .../OpenNexus生产化实施进度-2026-09-08.md | 10 +++++ .../features/chat/ChatPersonaDialog.spec.ts | 45 +++++++++++++++++++ .../src/features/chat/ChatPersonaDialog.vue | 38 +++++++++++++++- 7 files changed, 146 insertions(+), 3 deletions(-) diff --git a/backend/app/routes.py b/backend/app/routes.py index 8f948b3..d1a189e 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -1624,6 +1624,12 @@ def get_global_persona(): return load_persona() +@router.get("/settings/persona/legacy", tags=["Settings"]) +def get_legacy_persona_preview(): + from app.services.persona_settings import legacy_persona_preview + return legacy_persona_preview() + + @router.put("/settings/persona", response_model=PersonaSettings, tags=["Settings"]) def put_global_persona(request: PersonaSettings): return save_persona(request) diff --git a/backend/app/services/persona_settings.py b/backend/app/services/persona_settings.py index 081cd92..9416d6c 100644 --- a/backend/app/services/persona_settings.py +++ b/backend/app/services/persona_settings.py @@ -42,6 +42,21 @@ def load_persona(): return PersonaSettings.model_validate_json(row[0]) if row else PersonaSettings() +def legacy_persona_preview(): + """Explicit read-only import source; no automatic Vault ownership inference.""" + from app.errors import ApiError + from app.services.desktop_notes import call + if not _desktop(): + raise ApiError(404, 'RESOURCE_NOT_FOUND', '此入口仅用于桌面人设导入。') + call('persona.get', id='default') # Revalidate the authenticated Vault at Host. + with closing(connection()) as conn: + row = conn.execute("SELECT data FROM global_persona WHERE id=1").fetchone() + if not row: + return {'available': False, 'persona': None} + source = PersonaSettings.model_validate_json(row[0]) + return {'available': True, 'persona': source.model_dump(exclude={'revision'})} + + def save_persona(settings): if _desktop(): from uuid import uuid4 diff --git a/backend/tests/test_global_persona.py b/backend/tests/test_global_persona.py index dcb880c..8619597 100644 --- a/backend/tests/test_global_persona.py +++ b/backend/tests/test_global_persona.py @@ -89,3 +89,33 @@ def test_desktop_missing_persona_does_not_import_unowned_global_data(monkeypatch monkeypatch.setattr(persona_settings, '_desktop', lambda: True) monkeypatch.setattr(desktop_notes, 'call', lambda *args, **kwargs: None) assert load_persona() == PersonaSettings() + + +def test_legacy_preview_requires_host_scope_and_never_mutates_source(monkeypatch): + from app.services import persona_settings, desktop_notes + original = save_persona(PersonaSettings(system_prompt='legacy preview', version=0)) + monkeypatch.setattr(persona_settings, '_desktop', lambda: True) + calls = [] + def allowed(method, **params): + calls.append((method, params)) + return None + monkeypatch.setattr(desktop_notes, 'call', allowed) + preview = persona_settings.legacy_persona_preview() + assert preview['available'] is True + assert preview['persona']['system_prompt'] == 'legacy preview' + assert 'revision' not in preview['persona'] + assert calls == [('persona.get', {'id': 'default'})] + def denied(*args, **kwargs): + raise ApiError(409, 'VAULT_PERMISSION_CHANGED', 'controlled') + monkeypatch.setattr(desktop_notes, 'call', denied) + with pytest.raises(ApiError): + persona_settings.legacy_persona_preview() + monkeypatch.setattr(persona_settings, '_desktop', lambda: False) + assert load_persona() == original + + +def test_legacy_preview_reports_no_source_without_creating_persona(monkeypatch): + from app.services import persona_settings, desktop_notes + monkeypatch.setattr(persona_settings, '_desktop', lambda: True) + monkeypatch.setattr(desktop_notes, 'call', lambda *args, **kwargs: None) + assert persona_settings.legacy_persona_preview() == {'available': False, 'persona': None} diff --git a/docs/contracts/Sync-v1契约.md b/docs/contracts/Sync-v1契约.md index 657617c..83e4e7b 100644 --- a/docs/contracts/Sync-v1契约.md +++ b/docs/contracts/Sync-v1契约.md @@ -88,4 +88,7 @@ Host 在写入 journal、捕获外部修改和上传前验证记录;未知字 Core 通过绑定 Vault 的 workspace.persona.get/write RPC 读写;Host 先校验 Vault、路径和数据,再执行现有 CAS journal。设置 HTTP DTO 增加 revision 内容摘要,供表单保存时作为 expected;此摘要不写入逻辑 data。整数 version 用作显示版本,不能代替摘要 CAS。重复 operation_id 与同一输入返回持久回执,回执包含所提交记录的实际摘要。过期摘要以 PERSONA_VERSION_CONFLICT 返回,表单保留错误状态。 -桌面运行的聊天/Agent 获取当前 Vault 人设,缺失记录得到空人设,不回退全局 SQLite。Web 模式保持原来的全局存储。旧全局数据保留,不自动复制到任意 Vault;明确归属的迁移/导入仍待完成。表单加载时保留 Vault 身份,切换 Vault 后禁止提交旧表单。头像仍只在本机保存,不在此记录内。真实双设备人设收敛、旧数据导入与跨版本兼容需另行验收。 +桌面运行的聊天/Agent 获取当前 Vault 人设,缺失记录得到空人设,不回退全局 SQLite。Web 模式保持原来的全局存储。旧全局数据保留,不自动复制到任意 Vault;用户可通过下述预览流程明确选择导入当前 Vault。表单加载时保留 Vault 身份,切换 Vault 后禁止提交旧表单。头像仍只在本机保存,不在此记录内。独立设备 UI、导入中断故障矩阵与跨版本兼容需另行验收;本机两个实际客户端的同改收敛已另有测试证据。 + + +旧全局人设导入使用只读 GET `/api/settings/persona/legacy`,仅桌面模式提供,并先向 Host 验证当前 Vault。返回 available 与旧人设内容,不返回旧记录 revision,也不写入任何 Workspace 记录。UI 展示预览后,用户可以点击“填入当前表单”;这只替换可编辑内容,保留当前目标的 version/revision。最终保存沿用正常人设 PUT、Host CAS 与 journal。取消预览/关闭表单不会导入,切换 Vault 后迟到响应被丢弃,旧 SQLite 来源始终保留。没有自动清除或把读取行为作为迁移完成标记。 diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index 00d37ac..37e2412 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -700,3 +700,13 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 扩展 core_workspace 实际 Python Core HTTP + Host 管道测试:读取空人设、保存得到 64 位摘要、同一操作和输入重放 20 次返回相同响应、用空旧摘要重新提交得到 409、不同 Vault 读取被拒绝,正常读取仍等于原回执。Host 待处理操作计数仅增加 1,持久记录 hash 等于 HTTP revision。 - cargo test --features desktop --test core_workspace --test sync_push:两项集成通过、1 项辅助进程入口 ignored;分别 2.79 秒和 25.44 秒,后者同时包含既有真实 100 MiB 上传断点/重启用例。日志 .build/persona-layout-integration.log。全目标 Clippy -D warnings 通过,日志 .build/persona-layout-integration-clippy.log。本轮仅扩充集成测试,没有重复无关生产代码回归。 - 证据范围为本机隔离服务与两个 Rust 客户端,并非两台独立硬件上的最终 UI。此处只覆盖同改与两种取舍,不覆盖全部 S-03 的改对删/rename/副本/历史恢复矩阵,也不完成旧全局人设明确归属导入或发布版本兼容。完整生产化目标保持未完成。 + + +## 增量:明确选择旧全局人设导入目标 + +- 新增桌面专用只读 /api/settings/persona/legacy。先经 Host persona.get 验证当前 Vault,再读取旧 SQLite 来源;无来源返回 available=false。源数据不含目标 CAS revision,不修改 Workspace,也不删除或重写旧人设行。 +- 人设对话框新增查看旧全局人设、内容预览和填入当前表单两步。预览文字按普通 Vue 文本渲染,限制长文本展示高度;填入操作仅复制 name/system_prompt/dialogue_pairs,保留当前工作区 version/revision。用户仍需正常点击保存,沿用 Host CAS/journal。界面说明替换草稿与保留来源的行为。 +- 预览响应持有发起时 Vault 标识;切库/关闭后不将迟到内容填入新工作区。测试验证预览与复制阶段均没有 PUT、保存使用目标摘要而非源版本、切库迟到响应不展示、无来源、Host 拒绝时不能读取,以及旧数据保持不变。 +- 前端人设定向 6 项、Python 人设定向 7 项通过;完整前端 101 文件/529 项通过,日志 .build/persona-import-frontend.log。完整后端与类型检查结果另列。 +- 此流程是用户明确选择的复制导入,不是自动所有权推断;没有实现旧来源清除。完整导入进程中断矩阵、独立设备 UI 与发布兼容验收仍需继续,完整生产化目标保持未完成。 +- 最终后端全套 904 通过、1 项依赖弃用警告,81.49 秒;前端 type-check 通过。日志 .build/persona-import-python.log、.build/persona-import-types.log。本轮无 Rust 生产代码改动,未重复 Rust 全套;用户 Vault 修改保持原状。 diff --git a/frontend/src/features/chat/ChatPersonaDialog.spec.ts b/frontend/src/features/chat/ChatPersonaDialog.spec.ts index 4141295..47c5ec9 100644 --- a/frontend/src/features/chat/ChatPersonaDialog.spec.ts +++ b/frontend/src/features/chat/ChatPersonaDialog.spec.ts @@ -69,3 +69,48 @@ it('sends the loaded persona revision and refuses a form from another Vault', as expect(wrapper.text()).toContain('工作区已切换') } finally { wrapper.unmount(); desktopMode.mockRestore() } }) + + +it('previews legacy content without writing and preserves the Vault CAS when copied', async () => { + const desktopMode = vi.spyOn(desktop, 'isDesktop').mockReturnValue(true) + useWorkspaceStore().vaultId = 'import-target' + vi.mocked(apiClient.put).mockClear() + vi.mocked(apiClient.get).mockImplementation(async url => url.endsWith('/legacy') + ? { available: true, persona: { version: 90, name: 'Old', system_prompt: 'Legacy prompt', dialogue_pairs: [{ user: 'Question', assistant: 'Answer' }] } } + : { version: 3, revision: 'c'.repeat(64), name: 'Current', system_prompt: 'Current prompt', dialogue_pairs: [] }) + const wrapper = mount(ChatPersonaDialog) + try { + await flushPromises() + await wrapper.findAll('button').find(button => button.text().includes('查看旧全局人设'))!.trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('Legacy prompt') + expect((wrapper.get('.persona-prompt').element as HTMLTextAreaElement).value).toBe('Current prompt') + expect(apiClient.put).not.toHaveBeenCalled() + await wrapper.findAll('button').find(button => button.text().includes('填入当前表单'))!.trigger('click') + expect(apiClient.put).not.toHaveBeenCalled() + expect((wrapper.get('.persona-prompt').element as HTMLTextAreaElement).value).toBe('Legacy prompt') + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(apiClient.put).toHaveBeenCalledWith('/api/settings/persona', expect.objectContaining({ version: 3, revision: 'c'.repeat(64), name: 'Old', system_prompt: 'Legacy prompt' })) + } finally { wrapper.unmount(); desktopMode.mockRestore() } +}) + +it('discards a late legacy preview after switching Vaults', async () => { + const desktopMode = vi.spyOn(desktop, 'isDesktop').mockReturnValue(true) + const workspace = useWorkspaceStore() + workspace.vaultId = 'before' + let complete!: (value: unknown) => void + vi.mocked(apiClient.get).mockImplementation(async url => url.endsWith('/legacy') + ? await new Promise(resolve => { complete = resolve }) + : { version: 0, revision: '', name: '', system_prompt: '', dialogue_pairs: [] }) + const wrapper = mount(ChatPersonaDialog) + try { + await flushPromises() + await wrapper.findAll('button').find(button => button.text().includes('查看旧全局人设'))!.trigger('click') + workspace.vaultId = 'after' + complete({ available: true, persona: { version: 1, name: 'Late', system_prompt: 'Stale source', dialogue_pairs: [] } }) + await flushPromises() + expect(wrapper.text()).not.toContain('Stale source') + expect(wrapper.findAll('button').some(button => button.text().includes('填入当前表单'))).toBe(false) + } finally { wrapper.unmount(); desktopMode.mockRestore() } +}) diff --git a/frontend/src/features/chat/ChatPersonaDialog.vue b/frontend/src/features/chat/ChatPersonaDialog.vue index 4e72d55..47310b0 100644 --- a/frontend/src/features/chat/ChatPersonaDialog.vue +++ b/frontend/src/features/chat/ChatPersonaDialog.vue @@ -14,9 +14,12 @@ const error = ref('') const remote = reactive({version:0,name:'',system_prompt:'',dialogue_pairs:[]}) const workspace = useWorkspaceStore() let loadedVault = workspace.vaultId -watch(() => workspace.vaultId, () => { if (isDesktop()) { ready.value = false; error.value = t('工作区已切换,请重新打开人设设置。', 'Workspace changed. Reopen persona settings.') } }) +watch(() => workspace.vaultId, () => { if (isDesktop()) { ready.value = false; legacy.value = null; error.value = t('工作区已切换,请重新打开人设设置。', 'Workspace changed. Reopen persona settings.') } }) const ready = ref(false) const saving = ref(false) +const legacy = ref(null) +const legacyLoading = ref(false) +const legacyMessage = ref('') const loading = ref(0) const dialog = ref() const previousFocus = document.activeElement as HTMLElement | null @@ -29,6 +32,26 @@ async function loadGlobal() { try { const result = await apiClient.get('/api/settings/persona'); if (active && (!isDesktop() || vault === workspace.vaultId)) { Object.assign(remote,result); loadedVault = vault; ready.value = true } } catch { if (active) error.value = t('无法加载全局人设,请重试。', 'Could not load global persona. Retry.') } } +async function previewLegacy() { + if (!ready.value || saving.value || legacyLoading.value) return + const vault = workspace.vaultId + legacyLoading.value = true; legacyMessage.value = ''; legacy.value = null + try { + const result = await apiClient.get<{ available: boolean; persona: GlobalPersona | null }>('/api/settings/persona/legacy') + if (!active || vault !== workspace.vaultId || loadedVault !== vault) return + legacy.value = result.available ? result.persona : null + if (!legacy.value) legacyMessage.value = t('没有可导入的旧全局人设。', 'No legacy global persona is available.') + } catch { if (active && vault === workspace.vaultId) legacyMessage.value = t('无法读取旧人设,请重试。', 'Could not read the legacy persona. Retry.') } + finally { legacyLoading.value = false } +} +function useLegacy() { + if (!legacy.value || !ready.value || saving.value || loadedVault !== workspace.vaultId) return + remote.name = legacy.value.name + remote.system_prompt = legacy.value.system_prompt + remote.dialogue_pairs = legacy.value.dialogue_pairs.map(pair => ({ ...pair })) + legacy.value = null + legacyMessage.value = t('已填入表单;点击保存才会写入当前工作区,旧数据保留。', 'Copied into the form. Save to write to this workspace; legacy data is retained.') +} onMounted(() => { if (dialog.value) restoreScroll = lockDialogScroll(dialog.value); dialog.value?.showModal(); void loadGlobal() }) onBeforeUnmount(() => { active = false; dialog.value?.close(); restoreScroll?.(); previousFocus?.focus() }) async function chooseAvatar(event: Event, field: 'aiAvatar' | 'userAvatar') { @@ -74,10 +97,20 @@ async function save() {

{{ t('人设与头像', 'Persona and avatars') }}

{{ isDesktop() ? t('工作区人设 · 随当前 Vault 同步,应用于此工作区的对话与智能体。旧全局人设不会自动导入。', 'Workspace persona · Syncs with this Vault and applies to its chats and agents. Legacy global personas are not imported automatically.') : t('全局人设 · 应用于连接此 AI Core 的所有对话与智能体。留空的提示词和对话示例不会拼入请求。', 'Global persona · Applies to all chats and agents connected to this AI Core. Empty prompts and examples are omitted.') }}

{{ t('正在加载全局设置', 'Loading global settings') }}

+
+ +

{{ legacyMessage }}

+
+

{{ t('预览旧数据;填入表单会替换当前草稿,保存后才生效。', 'Preview only. Copying replaces the current draft; changes take effect after saving.') }}

+ {{ legacy.name }}
{{ legacy.system_prompt }}
+
{{ pair.user }}
{{ pair.assistant }}
+ +
+
-