diff --git a/docs/CurrentState/CharacterIdentityContract.md b/docs/CurrentState/CharacterIdentityContract.md index b62e15fb..b5186916 100644 --- a/docs/CurrentState/CharacterIdentityContract.md +++ b/docs/CurrentState/CharacterIdentityContract.md @@ -162,6 +162,7 @@ alias key 是 Rust 内部 character stem,不是 avatar filename。 - 删除角色卡。 - 当 `delete_chats=true` 时,通过 resolver 找到当前身份对应的物理聊天目录并删除。 - 这包括已经确认的 alias 目录,也包括 safe legacy discovery 找到的历史错误目录。 +- 绑定的世界书按 [§10](#10-角色世界书) 处理。 这意味着 delete 语义偏向用户意图:“删除这个角色及其聊天”,而不是只删除 canonical directory。 @@ -199,12 +200,22 @@ alias key 是 Rust 内部 character stem,不是 avatar filename。 ## 10. 角色世界书 -- 角色使用 `data.extensions.world` 绑定的那本世界书。卡内的 `character_book` 只是它的副本,在保存和导出时刷新;两者不一致是正常的,不算冲突。 -- 内嵌书只在卡片到达时处理:导入、替换、“导入角色卡的世界书”。规则只写在 `CharacterService::adopt_embedded_lorebook` 一处。 +角色使用 `data.extensions.world` 绑定的那本世界书。卡内的 `character_book` 只是它的副本,在保存和导出时刷新;两者不一致是正常的,不算冲突。 + +接管内嵌书: + +- 只在卡片到达时处理:导入、替换、“导入角色卡的世界书”。规则在 `CharacterService::adopt_embedded_lorebook`,前端的询问和刷新在 `world-info.js` 的 `completeCharacterLorebookAdoption`。 - 绑定只在接管或用户手动链接时改变。替换时先写回原绑定,再接管;原来没有绑定或旧卡读不出时,写回空绑定。 -- 新书以 `character_book.name` 命名,没有名字时用 `<角色名>'s Lorebook`。名字被占用时依次尝试 `名字 (1)`、`名字 (2)`……,遇到条目完全相同的书就直接复用。 -- 接管时,按角色当前在用的书分三种情况。其中“本地版本”指名字是新书原名或它的编号副本的那本书。 - - 内容相同,且是本地版本:不做任何改动。 +- 新书以 `character_book.name` 命名,没有名字时用 `<角色名>'s Lorebook`。名字被占用时依次用 `名字 (1)`、`名字 (2)`……,遇到条目完全相同的书就直接复用。 +- 按角色当前在用的书处理。“本地版本”指名字是新书原名或其编号副本的那本书。 + - 内容相同,且是本地版本:不改动。 - 内容不同:询问一次;“更新当前”只对本地版本提供。 - - 其余情况:直接链接分配到的书,必要时新建。 -- 前端的询问和刷新由 `world-info.js` 的 `completeCharacterLorebookAdoption` 负责。 + - 其余情况:链接分配到的书,必要时新建。 + +删除角色: + +- 后端只删角色,不删世界书。 +- 前端 `deleteCharacter` 只删除没有其他使用者的绑定世界书。 + - 使用者包括其他角色的绑定和附加世界书、全局启用的世界书、Persona 的世界书。 + - 删除失败的角色仍算使用者。判断在刷新列表之前完成。 +- 不检查聊天级世界书,也不检查读不出的角色卡。 diff --git a/docs/FrontendHostContract.md b/docs/FrontendHostContract.md index 436b12b6..c3342a74 100644 --- a/docs/FrontendHostContract.md +++ b/docs/FrontendHostContract.md @@ -235,6 +235,8 @@ ESM `/scripts/secrets.js` 的 `writeSecret`、`deleteSecret`、`readSecretState` `/api/characters/import` 的响应比上游多一个 `lorebook` 字段,形状与 `api.characterCards` 相同。角色已经写入、但世界书接管失败时,`lorebook.status` 为 `failed`,错误由后端提示。 +`/api/characters/delete` 只删除角色和请求删除的聊天,不删除世界书。界面删卡时如何清理世界书,见[角色世界书](CurrentState/CharacterIdentityContract.md#10-角色世界书)。 + 第一方聊天读写直连内部 transport,不产生兼容路由的 Fetch 请求;外部观察应使用既有业务事件。`/api/chats/get`、`/api/chats/group/get`、`/api/chats/save`、`/api/chats/group/save` 仍供扩展调用。保存成功为 `200 { ok: true }`;integrity 冲突按错误码识别,返回 `400 { error: 'integrity' }`。 聊天 get/save 遵循 [ChatPayload §1.1](CurrentState/ChatPayload.md#11-统一格式底线)。兼容 get 返回保留记录原始 JSON 文本的流式数组(含 header 和全部 swipes),无记录返回空数组。正文错误使 `.json()` / `.text()` 或 jQuery 请求失败,即使 HTTP 状态为 200;取消请求或正文读取会停止后续读取。 diff --git a/src-tauri/crates/tauritavern/src/app/contract_tests/character.rs b/src-tauri/crates/tauritavern/src/app/contract_tests/character.rs index 73e7380b..000dca87 100644 --- a/src-tauri/crates/tauritavern/src/app/contract_tests/character.rs +++ b/src-tauri/crates/tauritavern/src/app/contract_tests/character.rs @@ -830,51 +830,6 @@ async fn character_service_replaces_an_unreadable_existing_card() { let _ = fs::remove_dir_all(root).await; } -#[tokio::test] -async fn character_delete_removes_only_string_linked_lorebooks() { - let root = temp_root("character-delete-lorebook"); - let (service, world_repository) = character_service_with_world_repository(&root).await; - for name in ["Lore", "42"] { - world_repository - .save_world_info(name, &world_info(name)) - .await - .expect("save world info"); - } - for (name, world) in [("Linked", json!("Lore")), ("Opaque", json!(42))] { - let card = character_card(name, json!({ "world": world })); - fs::write( - root.join(format!("default-user/characters/{name}.png")), - character_png(&card), - ) - .await - .expect("write character card"); - service - .delete_character(DeleteCharacterDto { - name: name.to_string(), - delete_chats: false, - }) - .await - .expect("delete character"); - } - - assert!( - world_repository - .get_world_info("Lore") - .await - .expect("read linked world") - .is_none() - ); - assert!( - world_repository - .get_world_info("42") - .await - .expect("read numeric world") - .is_some() - ); - - let _ = fs::remove_dir_all(root).await; -} - #[tokio::test] async fn character_service_replace_rejects_non_segment_storage_identity() { let root = temp_root("character-replace-invalid-storage-identity"); diff --git a/src-tauri/crates/tauritavern/src/app/contract_tests/mod.rs b/src-tauri/crates/tauritavern/src/app/contract_tests/mod.rs index 6ead8842..a00c4ce5 100644 --- a/src-tauri/crates/tauritavern/src/app/contract_tests/mod.rs +++ b/src-tauri/crates/tauritavern/src/app/contract_tests/mod.rs @@ -32,9 +32,9 @@ use tt_application::dto::agent_dto::{ }; use tt_application::dto::character_dto::{ CharacterLorebookAdoptionChoice, CharacterLorebookAdoptionDto, CreateCharacterDto, - DeleteCharacterDto, ExportCharacterContentDto, ImportCharacterDto, MergeCharacterCardDataDto, - ReplaceCharacterDto, ResolveCharacterLorebookAdoptionDto, UpdateAvatarDto, - UpdateCharacterCardDataDto, UpdateCharacterDto, + ExportCharacterContentDto, ImportCharacterDto, MergeCharacterCardDataDto, ReplaceCharacterDto, + ResolveCharacterLorebookAdoptionDto, UpdateAvatarDto, UpdateCharacterCardDataDto, + UpdateCharacterDto, }; use tt_application::dto::chat_completion_dto::ChatCompletionGenerateRequestDto; use tt_application::errors::ApplicationError; diff --git a/src-tauri/crates/tt-application/src/services/character_service.rs b/src-tauri/crates/tt-application/src/services/character_service.rs index 9de7e096..89ec3219 100644 --- a/src-tauri/crates/tt-application/src/services/character_service.rs +++ b/src-tauri/crates/tt-application/src/services/character_service.rs @@ -364,33 +364,6 @@ impl CharacterService { .agent_workspace_lifecycle_service .lock_run_lifecycle() .await; - let linked_world = match self.repository.read_character_card_json(&dto.name).await { - Ok(raw_json) => match serde_json::from_str::(&raw_json) { - Ok(card) => card - .pointer("/data/extensions/world") - .and_then(Value::as_str) - .filter(|world| !world.is_empty()) - .map(ToString::to_string), - Err(error) => { - tracing::error!( - target: tt_contracts::observability::USER_VISIBLE_ERROR, - "Deleting character '{}' without resolving its linked lorebook: {}", - dto.name, - error - ); - None - } - }, - Err(error) => { - tracing::error!( - target: tt_contracts::observability::USER_VISIBLE_ERROR, - "Deleting character '{}' without resolving its linked lorebook: {}", - dto.name, - error - ); - None - } - }; let workspace_targets = if dto.delete_chats { self.agent_workspace_targets_for_character_chats(&dto.name) .await? @@ -425,18 +398,6 @@ impl CharacterService { error ); } - if let Some(world) = linked_world - && let Err(error) = self.world_info_repository.delete_world_info(&world).await - && !matches!(&error, DomainError::NotFound(_)) - { - tracing::error!( - target: tt_contracts::observability::USER_VISIBLE_ERROR, - "Deleted character '{}' but could not delete linked lorebook '{}': {}", - dto.name, - world, - error - ); - } Ok(()) } diff --git a/src/locales/en.json b/src/locales/en.json index 43b7ed12..8b081777 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -105,5 +105,7 @@ "The lorebook was changed elsewhere; nothing was applied": "The lorebook was changed elsewhere; nothing was applied", "Lorebook refresh failed": "Lorebook refresh failed", "Lorebook import failed": "Lorebook import failed", - "Character imported; lorebook import failed": "Character imported; lorebook import failed" + "Character imported; lorebook import failed": "Character imported; lorebook import failed", + "Also deleted lorebook \"${0}\"": "Also deleted lorebook \"${0}\"", + "Character deleted; lorebook cleanup failed": "Character deleted; lorebook cleanup failed" } diff --git a/src/locales/zh-cn.json b/src/locales/zh-cn.json index bc76de0b..ea595bbc 100644 --- a/src/locales/zh-cn.json +++ b/src/locales/zh-cn.json @@ -4723,5 +4723,7 @@ "The lorebook was changed elsewhere; nothing was applied": "世界书已在别处被修改,未作更改", "Lorebook refresh failed": "世界书刷新失败", "Lorebook import failed": "世界书导入失败", - "Character imported; lorebook import failed": "角色已导入,但世界书导入失败" + "Character imported; lorebook import failed": "角色已导入,但世界书导入失败", + "Also deleted lorebook \"${0}\"": "已一并删除世界书「${0}」", + "Character deleted; lorebook cleanup failed": "角色已删除,但世界书清理失败" } diff --git a/src/locales/zh-tw.json b/src/locales/zh-tw.json index 7b023bd4..edf08efc 100644 --- a/src/locales/zh-tw.json +++ b/src/locales/zh-tw.json @@ -5233,5 +5233,7 @@ "The lorebook was changed elsewhere; nothing was applied": "世界書已在別處被修改,未作變更", "Lorebook refresh failed": "世界書重新整理失敗", "Lorebook import failed": "世界書匯入失敗", - "Character imported; lorebook import failed": "角色已匯入,但世界書匯入失敗" + "Character imported; lorebook import failed": "角色已匯入,但世界書匯入失敗", + "Also deleted lorebook \"${0}\"": "已一併刪除世界書「${0}」", + "Character deleted; lorebook cleanup failed": "角色已刪除,但世界書清理失敗" } diff --git a/src/script.js b/src/script.js index 98c16ceb..a3002019 100644 --- a/src/script.js +++ b/src/script.js @@ -131,8 +131,8 @@ import { getWorldInfoSettings, setWorldInfoSettings, world_names, + selected_world_info, flushWorldInfoSaves, - updateWorldInfoList, deleteWorldInfo, importEmbeddedWorldInfo, completeCharacterLorebookAdoption, @@ -12639,7 +12639,7 @@ export async function handleDeleteCharacter(this_chid, delete_chats) { } /** - * Deletes a character completely, including associated chats if specified + * Deletes characters and their unreferenced primary lorebooks, including chats if specified. * * @param {string|string[]} characterKey - The key (avatar) of the character to be deleted * @param {Object} [options] - Optional parameters for the deletion @@ -12667,7 +12667,8 @@ export async function deleteCharacter(characterKey, { deleteChats = true } = {}) return false; } - let deleted = false; + const deletedAvatars = new Set(); + const candidateLorebooks = new Set(); for (const key of characterKey) { const character = characters.find(x => x.avatar == key); @@ -12675,6 +12676,7 @@ export async function deleteCharacter(characterKey, { deleteChats = true } = {}) toastr.warning(t`Character ${key} not found. Skipping deletion.`); continue; } + const world = character.data?.extensions?.world; const chid = characters.indexOf(character); const pastChats = deleteChats @@ -12697,6 +12699,9 @@ export async function deleteCharacter(characterKey, { deleteChats = true } = {}) toastr.error(`${response.status} ${response.statusText}`, t`Failed to delete character`); continue; } + deletedAvatars.add(character.avatar); + // Only strings identify lorebooks; card extensions preserve other JSON types. + if (typeof world === 'string' && world) candidateLorebooks.add(world); try { const { clearCharacterSkillImportReminders } = await import('./scripts/tauri/agent-skills/reminders.js'); @@ -12718,7 +12723,23 @@ export async function deleteCharacter(characterKey, { deleteChats = true } = {}) } await eventSource.emit(event_types.CHARACTER_DELETED, { id: chid, character: character }); - deleted = true; + } + + // Decide from the loaded list before a refresh can replace it, including after partial failure. + const remainingCharacterFiles = new Set(); + for (const character of characters) { + if (deletedAvatars.has(character.avatar)) continue; + candidateLorebooks.delete(character.data?.extensions?.world); + remainingCharacterFiles.add(getCharaFilename(null, { manualAvatarKey: character.avatar })); + } + for (const lore of world_info.charLore ?? []) { + if (!remainingCharacterFiles.has(lore.name)) continue; + for (const world of lore.extraBooks ?? []) candidateLorebooks.delete(world); + } + for (const world of selected_world_info) candidateLorebooks.delete(world); + candidateLorebooks.delete(power_user.persona_description_lorebook); + for (const persona of Object.values(power_user.persona_descriptions)) { + candidateLorebooks.delete(persona.lorebook); } try { @@ -12727,15 +12748,18 @@ export async function deleteCharacter(characterKey, { deleteChats = true } = {}) console.error('Character deleted, but the UI refresh failed:', error); toastr.warning(t`Character deleted, but the character list could not be refreshed.`); } - if (deleted) { + for (const world of candidateLorebooks) { try { - await updateWorldInfoList(); + if (await deleteWorldInfo(world, { saveLinkedCharacter: false })) { + toastr.success(t`Also deleted lorebook "${world}"`); + } } catch (error) { - console.error('Character deleted, but the World/Lorebook list refresh failed:', error); + console.error('Character deleted, but its lorebook cleanup could not be completed:', error); + toastr.warning(error?.message, t`Character deleted; lorebook cleanup failed`); } } - return deleted; + return deletedAvatars.size > 0; } /** diff --git a/tests/browser/chat-navigation.test.mjs b/tests/browser/chat-navigation.test.mjs index b2fd9a2b..70ad7ee6 100644 --- a/tests/browser/chat-navigation.test.mjs +++ b/tests/browser/chat-navigation.test.mjs @@ -11,7 +11,7 @@ function chatPayload(fileName) { } test('chat persistence and navigation', async (context) => { - const { window, getModule, load, startHost } = createBrowserRuntime(); + const { window, getModule, load, startHost, initializeChatRendering } = createBrowserRuntime(); window.happyDOM.settings.timer.maxIntervalIterations = 1; window.happyDOM.settings.timer.maxIntervalTime = 0; const errors = []; @@ -215,14 +215,10 @@ test('chat persistence and navigation', async (context) => { payloads.delete('broken'); assert.equal(handles.size, 0); }); - const main = (await load('script.js')).namespace; + const main = await initializeChatRendering(); const groupChats = getModule('scripts/group-chats.js').namespace; const welcome = getModule('scripts/welcome-screen.js').namespace; const coldSwipes = getModule('scripts/tauri/chat/cold-swipes.js').namespace; - main.reloadMarkdownProcessor(); - getModule('tauri/main/services/chat-surface/chat-virtualization-state.js').namespace - .initializeChatVirtualization({ chat_virtualization_enabled: false }); - await getModule('scripts/system-messages.js').namespace.initSystemMessages(); main.eventSource.on(main.event_types.CHAT_CHANGED, id => changed.push(id)); window.fetch = async (url, options) => { diff --git a/tests/browser/runtime.mjs b/tests/browser/runtime.mjs index 4a5a37aa..d31205af 100644 --- a/tests/browser/runtime.mjs +++ b/tests/browser/runtime.mjs @@ -159,5 +159,14 @@ export function createBrowserRuntime() { } } - return { window, listeners, getModule, load, startHost }; + async function initializeChatRendering() { + const main = (await load('script.js')).namespace; + main.reloadMarkdownProcessor(); + getModule('tauri/main/services/chat-surface/chat-virtualization-state.js').namespace + .initializeChatVirtualization({ chat_virtualization_enabled: false }); + await getModule('scripts/system-messages.js').namespace.initSystemMessages(); + return main; + } + + return { window, listeners, getModule, load, startHost, initializeChatRendering }; } diff --git a/tests/browser/world-info-persistence.test.mjs b/tests/browser/world-info-persistence.test.mjs index 2fc1ba52..ac7edcd4 100644 --- a/tests/browser/world-info-persistence.test.mjs +++ b/tests/browser/world-info-persistence.test.mjs @@ -2,6 +2,56 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createBrowserRuntime, createPaginationWaiter } from './runtime.mjs'; +test('deleting characters keeps a shared lorebook until its last user is deleted', async context => { + const { window, getModule, startHost, initializeChatRendering } = createBrowserRuntime(); + context.after(() => window.happyDOM.close()); + await startHost(); + const main = await initializeChatRendering(); + const worldInfo = getModule('scripts/world-info.js').namespace; + main.setAnimationDuration(0); + getModule('scripts/tauri/setting/settings-persistence.js').namespace + .captureSettingsSaveState({}, { token: 'loaded' }); + + let characters = ['Alice', 'Bob'].map(name => ({ + name, avatar: `${name}.png`, chat: 'chat', shallow: false, + data: { extensions: { world: 'Shared' }, tags: [], creator_notes: '', creator: '' }, + })); + const worlds = new Set(['Shared']); + // An empty refresh must not make the surviving character's book appear unused. + let listReads = 0; + const notices = []; + window.toastr.success = message => notices.push(message); + window.fetch = async (url, options) => { + const body = options?.body ? JSON.parse(options.body) : undefined; + switch (url) { + case '/api/characters/delete': + characters = characters.filter(character => character.avatar !== body.avatar_url); + return window.Response.json({ ok: true }); + case '/api/characters/all': + return window.Response.json(++listReads === 1 ? [] : characters); + case '/api/groups/all': return window.Response.json([]); + case '/api/settings/get': return window.Response.json({ world_names: [...worlds] }); + case '/api/settings/save': return window.Response.json({ tauritavern_settings_revision: { token: 'saved' } }); + case '/api/worldinfo/delete': + worlds.delete(body.name); + return window.Response.json({ ok: true }); + default: throw new Error(`Unexpected request: ${url}`); + } + }; + await worldInfo.updateWorldInfoList(); + worldInfo.world_info.charLore = [{ name: 'Bob', extraBooks: ['Shared'] }]; + main.characters.push(...characters); + main.setCharacterId(0); + + assert.equal(await main.deleteCharacter('Alice.png', { deleteChats: false }), true); + assert.equal(worlds.has('Shared'), true); + await main.getCharacters(); + main.setCharacterId(0); + assert.equal(await main.deleteCharacter('Bob.png', { deleteChats: false }), true); + assert.equal(worlds.has('Shared'), false); + assert.deepEqual(notices, ['Also deleted lorebook "Shared"']); +}); + test('editing after a backend lorebook update preserves the new document', async context => { const { window, getModule, load, startHost } = createBrowserRuntime(); context.after(() => window.happyDOM.close());