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()
|
||||
|
||||
@@ -80,3 +80,12 @@ Host 在写入 journal、捕获外部修改和上传前验证记录;未知字
|
||||
`opennexus-records/v1/layout/sidebars.json` 使用 schema=1、kind=layout、id=sidebars。data 仅包含 primaryExpanded 布尔值、workspaceWidth 与 chatWidth 两个 200–520 的像素数值;允许小数以保留指针拖动结果。未知字段、窗口路径或越界宽度均拒绝。记录使用相同的 Vault 草稿、CAS 和冲突解决机制,初始上传/合并准备包含当前布局。
|
||||
|
||||
本机已有三项侧栏 localStorage 值仍作为初始偏好读取并保存,不删除来源。组件共享布局状态,远端值立即反映在侧栏;窗口可用空间不足时仅收窄渲染宽度,不改写同步偏好,恢复空间后恢复偏好宽度。布局记录不含本机窗口坐标、显示器信息、已打开文件路径或执行权限。本版本未支持的旧客户端不能被视作已验证兼容,跨版本发布兼容验收仍需覆盖新增记录类别。
|
||||
|
||||
|
||||
## 工作区人设记录 v1
|
||||
|
||||
桌面端使用 `opennexus-records/v1/persona/default.json`,schema=1、kind=persona、id=default。data 白名单为 version(0–2^53−1 整数)、name(最多 128 Unicode 标量)、system_prompt(最多 16000 Unicode 标量)、dialogue_pairs(最多 20 对,每对仅 user/assistant,各最多 8000 Unicode 标量);整条记录仍受 1 MiB 字节限制。人设正文是用户内容,不自动赋予权限或启动任务。未知字段一律拒绝。
|
||||
|
||||
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 后禁止提交旧表单。头像仍只在本机保存,不在此记录内。真实双设备人设收敛、旧数据导入与跨版本兼容需另行验收。
|
||||
|
||||
@@ -681,3 +681,13 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
||||
- 定向前端 4 项通过,包括远端布局不回流、本地布局写入、两个侧栏宽度分离、窄窗口限制不污染偏好。新增 Rust 测试覆盖布局记录完整信封/允许路径、非法宽度和设备字段拒绝。完整前端 101 文件、526 项通过,日志 .build/layout-sync-frontend.log。
|
||||
- 本轮覆盖现有侧栏布局,不代表全部逻辑数据已经完成:Skills、对话、Agent/人设、Provider 非秘密参数和安装清单仍需各自适配;真实双设备 UI 与旧版本新增记录兼容验收尚未完成。
|
||||
- 最终 desktop Rust 全目标 137 通过、12 ignored(库 122、Host 8、其余集成 7),日志 .build/layout-sync-rust.log;全目标 Clippy -D warnings 与前端 type-check 均通过,日志 .build/layout-sync-clippy.log、.build/layout-sync-types.log。没有改动用户 Vault 内容,完整生产化验收仍未完成。
|
||||
|
||||
|
||||
## 增量:桌面人设读取当前 Vault 记录
|
||||
|
||||
- 增加 persona/default 严格逻辑记录与 workspace.persona.get/write 专用 Core RPC。保留 Task RPC 范围,新增入口仍核验 Vault 和记录 kind/id。记录回执增加原始已提交 bytes 的 hash,以便响应丢失重放时返回正确摘要。
|
||||
- 桌面 persona_settings 经 Host 读取/保存,并以 revision 摘要作为 expected;数据 version 仅作为显示版本递增,不替代内容 CAS。调用方 operation_id 原样传递给 Host,未知提交结果不自动换 ID 重试。API 读写 handler 改为同步函数,由 FastAPI 工作线程执行阻塞 Host RPC。
|
||||
- 原来全局 SQLite 人设不猜测所属 Vault:桌面空库返回空人设,Web 仍使用全局存储且旧数据保留。聊天/Agent 的 apply_global_persona 沿既有调用链获取此作用域的数据。UI 显示工作区人设说明,并保留 revision;Vault 切换或迟到的旧 Vault 加载响应不能让旧表单保存到新 Vault。
|
||||
- Python 定向 5 项通过,覆盖全局原有行为、桌面摘要保存、过期摘要映射、Host operation_id、旧全局保留/不自动导入。Host 新测试覆盖真实 Workspace 重开、操作重放、CAS、未知字段和跨 Vault 拒绝。前端新增表单摘要往返与切 Vault 拒绝测试,定向 4 项通过;完整前端此前 526 项通过,类型检查通过,新加测试单独运行通过。
|
||||
- 该能力仍需真实双设备人设同步/冲突与明确归属旧数据导入验收,不能据此声明全部逻辑数据同步或生产化完成。
|
||||
- 最终 Python 全套 902 通过、1 项依赖弃用警告,105.26 秒;desktop Rust 全目标 138 通过、12 ignored(库 123、Host 8、其余集成 7),Clippy -D warnings 通过。日志 .build/persona-sync-python.log、.build/persona-sync-rust.log、.build/persona-sync-clippy.log;前端日志 .build/persona-sync-frontend.log 和 .build/persona-sync-types.log。完整目标继续保持未完成。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user