feat(sync): 将桌面人设绑定到 Vault 记录

This commit is contained in:
2026-09-09 06:55:35 +08:00
parent a751d80459
commit b83e8dfc2d
10 changed files with 217 additions and 9 deletions
@@ -3,6 +3,20 @@ use crate::workspace::{HostError, Result};
use serde::Deserialize;
use serde_json::Value;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct DialoguePair {
user: String,
assistant: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Persona {
version: u64,
name: String,
system_prompt: String,
dialogue_pairs: Vec<DialoguePair>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct Layout {
primary_expanded: bool,
@@ -91,6 +105,16 @@ fn markdown(value: &Markdown) -> bool {
}
pub fn validate(kind: &str, value: &Value) -> Result<()> {
let valid = match kind {
"persona" => {
let value: Persona = decode(value)?;
value.version <= 9007199254740991
&& value.name.chars().count() <= 128
&& value.system_prompt.chars().count() <= 16000
&& value.dialogue_pairs.len() <= 20
&& value.dialogue_pairs.iter().all(|pair| {
pair.user.chars().count() <= 8000 && pair.assistant.chars().count() <= 8000
})
}
"layout" => {
let value: Layout = decode(value)?;
let _ = value.primary_expanded;
+3 -1
View File
@@ -38,6 +38,7 @@ pub fn path_for(kind: &str, id: &str) -> Result<String> {
("theme_settings", "appearance") => {
Ok("opennexus-records/v1/theme-settings/appearance.json".into())
}
("persona", "default") => Ok("opennexus-records/v1/persona/default.json".into()),
("layout", "sidebars") => Ok("opennexus-records/v1/layout/sidebars.json".into()),
("preferences", "editor") => Ok("opennexus-records/v1/preferences/editor.json".into()),
_ => Err(HostError::new("RECORD_ID_INVALID")),
@@ -52,6 +53,7 @@ pub fn allowed(path_value: &str) -> bool {
"opennexus-records/v1/theme-settings/appearance.json"
| "opennexus-records/v1/preferences/editor.json"
| "opennexus-records/v1/layout/sidebars.json"
| "opennexus-records/v1/persona/default.json"
) {
return true;
}
@@ -195,7 +197,7 @@ impl Workspace {
let bytes = self.payload(operation, &[])?;
let record = validate(path, &bytes)?;
Ok(Some(
json!({"record":record,"deleted":receipt["result"]["deleted"],"state":receipt["state"]}),
json!({"record":record,"hash":crate::workspace::hash(&bytes),"deleted":receipt["result"]["deleted"],"state":receipt["state"]}),
))
}
}
@@ -81,6 +81,28 @@ pub fn dispatch(ws: &mut Workspace, request: &Value) -> Result<Value, String> {
bound(ws, &p.vault_id)?;
ws.record_list(p.offset, p.limit).map_err(|e| e.code)
}
"workspace.persona.get" => {
let p: RecordRead = decode(params)?;
bound(ws, &p.vault_id)?;
ws.record_get_kind("persona", &p.id)
.map(|v| v.unwrap_or(Value::Null))
.map_err(|e| e.code)
}
"workspace.persona.write" => {
let p: RecordWrite = decode(params)?;
bound(ws, &p.vault_id)?;
if p.record["kind"] != "persona" {
return Err("RECORD_SCHEMA_UNSUPPORTED".into());
}
let path = crate::records::path_for("persona", p.record["id"].as_str().unwrap_or(""))
.map_err(|e| e.code)?;
let bytes = serde_json::to_vec(&p.record).map_err(|_| "RECORD_SCHEMA_INVALID")?;
ws.write_operation(&path, &p.expected, &bytes, "local", &p.operation_id)
.map_err(|e| e.code)?;
ws.record_operation(&p.operation_id)
.map(|v| v.unwrap_or(Value::Null))
.map_err(|e| e.code)
}
"workspace.records.get" => {
let p: RecordRead = decode(params)?;
bound(ws, &p.vault_id)?;
@@ -212,6 +234,43 @@ pub fn dispatch(ws: &mut Workspace, request: &Value) -> Result<Value, String> {
mod tests {
use super::*;
#[test]
fn persona_is_vault_bound_durable_and_cas_protected() {
let root = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(root.path()).unwrap();
let value = json!({"schema":1,"kind":"persona","id":"default","data":{"version":1,"name":"老师","system_prompt":"解释","dialogue_pairs":[{"user":"你好","assistant":"您好"}]}});
let mut request = json!({"rpc":"workspace.persona.write","params":{"vault_id":ws.vault_id,"record":value,"expected":"","operation_id":uuid::Uuid::new_v4().to_string()}});
let first = dispatch(&mut ws, &request).unwrap();
assert_eq!(first["record"], value);
assert_eq!(dispatch(&mut ws, &request).unwrap(), first);
drop(ws);
let mut ws = Workspace::open(root.path()).unwrap();
let read =
json!({"rpc":"workspace.persona.get","params":{"vault_id":ws.vault_id,"id":"default"}});
assert_eq!(dispatch(&mut ws, &read).unwrap()["hash"], first["hash"]);
request["params"]["operation_id"] = json!(uuid::Uuid::new_v4().to_string());
request["params"]["record"]["data"]["name"] = json!("不同人设");
assert_eq!(
dispatch(&mut ws, &request).unwrap_err(),
"REVISION_CONFLICT"
);
request["params"]["expected"] = first["hash"].clone();
request["params"]["record"]["data"]["api_key"] = json!("forbidden");
assert_eq!(
dispatch(&mut ws, &request).unwrap_err(),
"RECORD_SCHEMA_INVALID"
);
request["params"]["record"]["data"]
.as_object_mut()
.unwrap()
.remove("api_key");
request["params"]["vault_id"] = json!("different-vault");
assert_eq!(
dispatch(&mut ws, &request).unwrap_err(),
"VAULT_PERMISSION_CHANGED"
);
assert_eq!(ws.pending_count().unwrap(), 1);
}
#[test]
fn rejects_stale_vault_and_unowned_fields_before_writes() {
let root = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(root.path()).unwrap();
@@ -3,6 +3,8 @@ import { beforeEach, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ChatPersonaDialog from './ChatPersonaDialog.vue'
import * as desktop from '@/services/platform/desktop'
import { useWorkspaceStore } from '@/stores/workspace'
import { useChatPreferences } from '@/stores/chatPreferences'
import { apiClient } from '@/services/apiClient'
@@ -44,3 +46,26 @@ it('persists separate local avatars and rejects remote avatar URLs', () => {
expect(() => useChatPreferences().save({...preferences.settings,aiAvatar:'https://example.com/avatar.png'})).toThrow()
expect(useChatPreferences().settings.aiAvatar).toBe(aiAvatar)
})
it('sends the loaded persona revision and refuses a form from another Vault', async () => {
const desktopMode = vi.spyOn(desktop, 'isDesktop').mockReturnValue(true)
const workspace = useWorkspaceStore()
workspace.vaultId = 'first'
vi.mocked(apiClient.get).mockResolvedValue({ version: 2, revision: 'a'.repeat(64), name: '', system_prompt: '', dialogue_pairs: [] })
vi.mocked(apiClient.put).mockClear()
const wrapper = mount(ChatPersonaDialog)
try {
await flushPromises()
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(apiClient.put).toHaveBeenCalledWith('/api/settings/persona', expect.objectContaining({ revision: 'a'.repeat(64) }))
vi.mocked(apiClient.put).mockClear()
workspace.vaultId = 'second'
await wrapper.vm.$nextTick()
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(apiClient.put).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('工作区已切换')
} finally { wrapper.unmount(); desktopMode.mockRestore() }
})
@@ -1,15 +1,20 @@
<script setup lang="ts">
import { lockDialogScroll } from '@/components/common/dialogScroll'
import { onMounted, onBeforeUnmount, reactive, ref } from 'vue'
import { onMounted, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useChatPreferences, validAvatar } from '@/stores/chatPreferences'
import { t } from '@/i18n'
import { apiClient } from '@/services/apiClient'
interface GlobalPersona { version: number; name: string; system_prompt: string; dialogue_pairs: Array<{user:string;assistant:string}> }
import { useWorkspaceStore } from '@/stores/workspace'
import { isDesktop } from '@/services/platform/desktop'
interface GlobalPersona { version: number; revision?: string; name: string; system_prompt: string; dialogue_pairs: Array<{user:string;assistant:string}> }
const emit = defineEmits<{ close: [] }>()
const preferences = useChatPreferences()
const draft = reactive({ ...preferences.settings })
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.') } })
const ready = ref(false)
const saving = ref(false)
const loading = ref(0)
@@ -20,7 +25,8 @@ let active = true
const generations = { aiAvatar: 0, userAvatar: 0 }
async function loadGlobal() {
error.value = ''; ready.value = false
try { const result = await apiClient.get<GlobalPersona>('/api/settings/persona'); if (active) { Object.assign(remote,result); ready.value = true } }
const vault = workspace.vaultId
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.') }
}
onMounted(() => { if (dialog.value) restoreScroll = lockDialogScroll(dialog.value); dialog.value?.showModal(); void loadGlobal() })
@@ -48,7 +54,7 @@ async function chooseAvatar(event: Event, field: 'aiAvatar' | 'userAvatar') {
}
function clearAvatar(field: 'aiAvatar' | 'userAvatar') { generations[field]++; draft[field] = '' }
async function save() {
if (loading.value || saving.value || !ready.value) return
if (loading.value || saving.value || !ready.value || (isDesktop() && loadedVault !== workspace.vaultId)) return
saving.value = true; error.value = ''
try {
const updated = await apiClient.put<GlobalPersona>('/api/settings/persona', JSON.parse(JSON.stringify(remote)))
@@ -66,7 +72,7 @@ async function save() {
<dialog ref="dialog" class="modal persona-dialog" aria-labelledby="persona-title" @cancel.prevent="emit('close')" @click="($event.target === dialog) && emit('close')">
<form @submit.prevent="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">{{ 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>
<fieldset :disabled="!ready || saving" class="persona-columns">
<div class="persona-primary">