feat(sync): 显式导入 Vault 前预览旧版人设
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 来源始终保留。没有自动清除或把读取行为作为迁移完成标记。
|
||||
|
||||
@@ -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 修改保持原状。
|
||||
|
||||
@@ -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() }
|
||||
})
|
||||
|
||||
@@ -14,9 +14,12 @@ const error = ref('')
|
||||
const remote = reactive<GlobalPersona>({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<GlobalPersona | null>(null)
|
||||
const legacyLoading = ref(false)
|
||||
const legacyMessage = ref('')
|
||||
const loading = ref(0)
|
||||
const dialog = ref<HTMLDialogElement>()
|
||||
const previousFocus = document.activeElement as HTMLElement | null
|
||||
@@ -29,6 +32,26 @@ async function loadGlobal() {
|
||||
try { const result = await apiClient.get<GlobalPersona>('/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() {
|
||||
<div class="persona-heading"><h2 id="persona-title">{{ t('人设与头像', 'Persona and avatars') }}</h2><button type="button" class="button-secondary" @click="emit('close')">{{ t('关闭', 'Close') }}</button></div>
|
||||
<p class="notice-banner">{{ 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.') }}</p>
|
||||
<p v-if="!ready" role="status">{{ t('正在加载全局设置', 'Loading global settings') }} <button type="button" class="button-secondary" @click="loadGlobal">{{ t('重试', 'Retry') }}</button></p>
|
||||
<section v-if="isDesktop()" class="legacy-persona">
|
||||
<button type="button" class="button-secondary" :disabled="!ready || saving || legacyLoading" @click="previewLegacy">{{ t('查看旧全局人设', 'Preview legacy global persona') }}</button>
|
||||
<p v-if="legacyMessage" role="status">{{ legacyMessage }}</p>
|
||||
<div v-if="legacy" class="item-card">
|
||||
<p>{{ t('预览旧数据;填入表单会替换当前草稿,保存后才生效。', 'Preview only. Copying replaces the current draft; changes take effect after saving.') }}</p>
|
||||
<strong>{{ legacy.name }}</strong><pre>{{ legacy.system_prompt }}</pre>
|
||||
<article v-for="(pair, index) in legacy.dialogue_pairs" :key="index"><pre>{{ pair.user }}</pre><pre>{{ pair.assistant }}</pre></article>
|
||||
<button type="button" class="button-secondary" :disabled="!ready || saving" @click="useLegacy">{{ t('填入当前表单', 'Copy into current form') }}</button>
|
||||
</div>
|
||||
</section>
|
||||
<fieldset :disabled="!ready || saving" class="persona-columns">
|
||||
<div class="persona-primary">
|
||||
<label class="field"><span>{{ t('人设名称', 'Persona name') }}</span><input v-model="remote.name" class="input" maxlength="128" :placeholder="t('例如:知识助理', 'For example: Knowledge assistant')" /></label>
|
||||
<label class="field"><span>{{ t('全局系统提示词', 'Global system prompt') }}</span><textarea v-model="remote.system_prompt" class="textarea persona-prompt" maxlength="16000" :placeholder="t('描述 AI 的身份、语气及回答要求;留空则不添加', 'Identity, tone and response requirements; leave blank to omit')" /></label>
|
||||
<label class="field"><span>{{ t('系统提示词', 'System prompt') }}</span><textarea v-model="remote.system_prompt" class="textarea persona-prompt" maxlength="16000" :placeholder="t('描述 AI 的身份、语气及回答要求;留空则不添加', 'Identity, tone and response requirements; leave blank to omit')" /></label>
|
||||
</div>
|
||||
<div class="persona-secondary">
|
||||
<details open class="ui-disclosure"><summary>{{ t('预设对话', 'Example dialogue') }}</summary>
|
||||
@@ -109,6 +142,7 @@ async function save() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.legacy-persona pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow: auto; }
|
||||
.persona-dialog { width: min(1120px, calc(100vw - 32px)); max-height: calc(100dvh - 48px); box-sizing: border-box; margin: auto; overflow: auto; color: var(--color-text-primary); background: var(--color-surface-primary); }
|
||||
.persona-columns { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: 24px; border: 0; margin: 0; padding: 0; min-width: 0; }
|
||||
.persona-primary, .persona-secondary { min-width: 0; }
|
||||
|
||||
Reference in New Issue
Block a user