diff --git a/backend/app/routes.py b/backend/app/routes.py index e5c0bfc..8f948b3 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -1620,12 +1620,12 @@ async def cancel_export(job_id: str) -> OperationResponse: @router.get("/settings/persona", response_model=PersonaSettings, tags=["Settings"]) -async def get_global_persona(): +def get_global_persona(): return load_persona() @router.put("/settings/persona", response_model=PersonaSettings, tags=["Settings"]) -async def put_global_persona(request: PersonaSettings): +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 6998d3c..081cd92 100644 --- a/backend/app/services/persona_settings.py +++ b/backend/app/services/persona_settings.py @@ -12,7 +12,8 @@ class DialoguePair(BaseModel): class PersonaSettings(BaseModel): model_config = ConfigDict(extra="forbid") - version: int = Field(default=0, ge=0) + version: int = Field(default=0, ge=0, le=9007199254740991) + revision: str = Field(default="", pattern=r"^(?:[0-9a-f]{64})?$") name: str = Field(default="", max_length=128) system_prompt: str = Field(default="", max_length=16000) dialogue_pairs: list[DialoguePair] = Field(default_factory=list, max_length=20) @@ -24,13 +25,42 @@ def connection(): return conn +def _desktop(): + from app.config import get_settings + return get_settings().environment == 'desktop' + + def load_persona(): + if _desktop(): + from app.services.desktop_notes import call + document = call('persona.get', id='default') + if document is None: + return PersonaSettings() + return PersonaSettings.model_validate({**document['record']['data'], 'revision': document['hash']}) with closing(connection()) as conn: row = conn.execute("SELECT data FROM global_persona WHERE id=1").fetchone() return PersonaSettings.model_validate_json(row[0]) if row else PersonaSettings() def save_persona(settings): + if _desktop(): + from uuid import uuid4 + from app import host_bridge + from app.services.desktop_notes import call + from app.errors import ApiError + if settings.version >= 9007199254740991: + raise ApiError(409, 'PERSONA_VERSION_EXHAUSTED', '人设版本已达到上限。') + data = settings.model_dump(exclude={'revision'}) + data['version'] += 1 + operation = host_bridge.operation_id.get() or str(uuid4()) + try: + receipt = call('persona.write', record={'schema': 1, 'kind': 'persona', 'id': 'default', 'data': data}, + expected=settings.revision, operation_id=operation) + except ApiError as error: + if error.code == 'REVISION_CONFLICT': + raise ApiError(409, 'PERSONA_VERSION_CONFLICT', '当前工作区人设已被修改,请重新打开表单后保存。') from None + raise + return PersonaSettings.model_validate({**receipt['record']['data'], 'revision': receipt['hash']}) from app.errors import ApiError with closing(connection()) as conn: conn.execute("BEGIN IMMEDIATE") diff --git a/backend/tests/test_global_persona.py b/backend/tests/test_global_persona.py index abc743c..dcb880c 100644 --- a/backend/tests/test_global_persona.py +++ b/backend/tests/test_global_persona.py @@ -46,3 +46,46 @@ def test_existing_provider_reads_latest_global_persona_for_complete_and_stream(m asyncio.run(run()) assert len(seen) == 2 assert all(text.count("全局人设 / Global persona") == 1 for text in seen) + + +def test_desktop_persona_uses_bound_host_cas_and_retains_legacy(monkeypatch): + from app.services import persona_settings, desktop_notes + from app import host_bridge + save_persona(PersonaSettings(system_prompt="legacy global")) + monkeypatch.setattr(persona_settings, '_desktop', lambda: True) + calls = [] + document = {'record': {'data': {'version': 7, 'name': 'Vault persona', + 'system_prompt': 'Scoped prompt', 'dialogue_pairs': []}}, 'hash': 'a' * 64} + def call(method, **params): + calls.append((method, params)) + if method == 'persona.get': + return document + if params['expected'] != document['hash']: + raise ApiError(409, 'REVISION_CONFLICT', 'controlled stale hash') + return {'record': params['record'], 'hash': 'b' * 64} + monkeypatch.setattr(desktop_notes, 'call', call) + loaded = load_persona() + assert loaded.revision == 'a' * 64 + assert apply_global_persona(request()).system.endswith('Scoped prompt') + token = host_bridge.operation_id.set('controlled-operation') + try: + saved = save_persona(loaded.model_copy(update={'name': 'Edited'})) + finally: + host_bridge.operation_id.reset(token) + assert saved.version == 8 and saved.revision == 'b' * 64 + method, params = calls[-1] + assert method == 'persona.write' and params['operation_id'] == 'controlled-operation' + assert 'revision' not in params['record']['data'] + with pytest.raises(ApiError) as error: + save_persona(loaded.model_copy(update={'revision': 'c' * 64})) + assert error.value.code == 'PERSONA_VERSION_CONFLICT' + monkeypatch.setattr(persona_settings, '_desktop', lambda: False) + assert load_persona().system_prompt == 'legacy global' + + +def test_desktop_missing_persona_does_not_import_unowned_global_data(monkeypatch): + from app.services import persona_settings, desktop_notes + save_persona(PersonaSettings(system_prompt='unowned global data')) + monkeypatch.setattr(persona_settings, '_desktop', lambda: True) + monkeypatch.setattr(desktop_notes, 'call', lambda *args, **kwargs: None) + assert load_persona() == PersonaSettings() diff --git a/frontend/src-tauri/src/preference_records.rs b/frontend/src-tauri/src/preference_records.rs index daf1fed..c7cec64 100644 --- a/frontend/src-tauri/src/preference_records.rs +++ b/frontend/src-tauri/src/preference_records.rs @@ -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, +} +#[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; diff --git a/frontend/src-tauri/src/records.rs b/frontend/src-tauri/src/records.rs index 0a316d7..b7e73bd 100644 --- a/frontend/src-tauri/src/records.rs +++ b/frontend/src-tauri/src/records.rs @@ -38,6 +38,7 @@ pub fn path_for(kind: &str, id: &str) -> Result { ("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"]}), )) } } diff --git a/frontend/src-tauri/src/workspace_broker.rs b/frontend/src-tauri/src/workspace_broker.rs index 9278b33..c087c12 100644 --- a/frontend/src-tauri/src/workspace_broker.rs +++ b/frontend/src-tauri/src/workspace_broker.rs @@ -81,6 +81,28 @@ pub fn dispatch(ws: &mut Workspace, request: &Value) -> Result { 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 { 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(); diff --git a/frontend/src/features/chat/ChatPersonaDialog.spec.ts b/frontend/src/features/chat/ChatPersonaDialog.spec.ts index e59909c..4141295 100644 --- a/frontend/src/features/chat/ChatPersonaDialog.spec.ts +++ b/frontend/src/features/chat/ChatPersonaDialog.spec.ts @@ -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() } +}) diff --git a/frontend/src/features/chat/ChatPersonaDialog.vue b/frontend/src/features/chat/ChatPersonaDialog.vue index fdfc20c..4e72d55 100644 --- a/frontend/src/features/chat/ChatPersonaDialog.vue +++ b/frontend/src/features/chat/ChatPersonaDialog.vue @@ -1,15 +1,20 @@