feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45

Merged
Kronecker merged 151 commits from feat/phase3-completion into main 2026-09-10 01:16:31 +08:00
7 changed files with 146 additions and 3 deletions
Showing only changes of commit 3ef4766da4 - Show all commits
+6
View File
@@ -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)
+15
View File
@@ -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
+30
View File
@@ -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}
+4 -1
View File
@@ -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; }