feat(sync): 将桌面人设绑定到 Vault 记录
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user