fix(characters): delete a character's lorebook only when unused

Deleting a character removed its bound lorebook unconditionally, even
when other characters, extra lorebooks, the global selection or a
persona still used it. The backend no longer deletes lorebooks.
deleteCharacter decides from the list the user acted on, before the
refresh, and removes a candidate through deleteWorldInfo only when
nothing else references it; characters that fail to delete still count
as users. Chat-level lorebooks and unreadable cards are not scanned.

Fixes #333.

Validation: pnpm run check.
This commit is contained in:
Darkatse 2026-10-16 20:02:45 -04:00
parent 76f7eb9612
commit c5fb6da7e4
12 changed files with 126 additions and 112 deletions

View file

@ -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 的世界书。
- 删除失败的角色仍算使用者。判断在刷新列表之前完成。
- 不检查聊天级世界书,也不检查读不出的角色卡。

View file

@ -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;取消请求或正文读取会停止后续读取。

View file

@ -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");

View file

@ -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;

View file

@ -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::<Value>(&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(())
}

View file

@ -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"
}

View file

@ -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": "角色已删除,但世界书清理失败"
}

View file

@ -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": "角色已刪除,但世界書清理失敗"
}

View file

@ -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;
}
/**

View file

@ -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) => {

View file

@ -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 };
}

View file

@ -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());