feat(sync): 显式导入 Vault 前预览旧版人设
This commit is contained in:
@@ -1624,6 +1624,12 @@ def get_global_persona():
|
|||||||
return load_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"])
|
@router.put("/settings/persona", response_model=PersonaSettings, tags=["Settings"])
|
||||||
def put_global_persona(request: PersonaSettings):
|
def put_global_persona(request: PersonaSettings):
|
||||||
return save_persona(request)
|
return save_persona(request)
|
||||||
|
|||||||
@@ -42,6 +42,21 @@ def load_persona():
|
|||||||
return PersonaSettings.model_validate_json(row[0]) if row else PersonaSettings()
|
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):
|
def save_persona(settings):
|
||||||
if _desktop():
|
if _desktop():
|
||||||
from uuid import uuid4
|
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(persona_settings, '_desktop', lambda: True)
|
||||||
monkeypatch.setattr(desktop_notes, 'call', lambda *args, **kwargs: None)
|
monkeypatch.setattr(desktop_notes, 'call', lambda *args, **kwargs: None)
|
||||||
assert load_persona() == PersonaSettings()
|
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}
|
||||||
|
|||||||
@@ -69,3 +69,48 @@ it('sends the loaded persona revision and refuses a form from another Vault', as
|
|||||||
expect(wrapper.text()).toContain('工作区已切换')
|
expect(wrapper.text()).toContain('工作区已切换')
|
||||||
} finally { wrapper.unmount(); desktopMode.mockRestore() }
|
} 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 remote = reactive<GlobalPersona>({version:0,name:'',system_prompt:'',dialogue_pairs:[]})
|
||||||
const workspace = useWorkspaceStore()
|
const workspace = useWorkspaceStore()
|
||||||
let loadedVault = workspace.vaultId
|
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 ready = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const legacy = ref<GlobalPersona | null>(null)
|
||||||
|
const legacyLoading = ref(false)
|
||||||
|
const legacyMessage = ref('')
|
||||||
const loading = ref(0)
|
const loading = ref(0)
|
||||||
const dialog = ref<HTMLDialogElement>()
|
const dialog = ref<HTMLDialogElement>()
|
||||||
const previousFocus = document.activeElement as HTMLElement | null
|
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 } }
|
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.') }
|
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() })
|
onMounted(() => { if (dialog.value) restoreScroll = lockDialogScroll(dialog.value); dialog.value?.showModal(); void loadGlobal() })
|
||||||
onBeforeUnmount(() => { active = false; dialog.value?.close(); restoreScroll?.(); previousFocus?.focus() })
|
onBeforeUnmount(() => { active = false; dialog.value?.close(); restoreScroll?.(); previousFocus?.focus() })
|
||||||
async function chooseAvatar(event: Event, field: 'aiAvatar' | 'userAvatar') {
|
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>
|
<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 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>
|
<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">
|
<fieldset :disabled="!ready || saving" class="persona-columns">
|
||||||
<div class="persona-primary">
|
<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('人设名称', '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>
|
||||||
<div class="persona-secondary">
|
<div class="persona-secondary">
|
||||||
<details open class="ui-disclosure"><summary>{{ t('预设对话', 'Example dialogue') }}</summary>
|
<details open class="ui-disclosure"><summary>{{ t('预设对话', 'Example dialogue') }}</summary>
|
||||||
@@ -109,6 +142,7 @@ async function save() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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-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-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; }
|
.persona-primary, .persona-secondary { min-width: 0; }
|
||||||
|
|||||||
Reference in New Issue
Block a user