mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-19 15:45:12 +00:00
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:
parent
76f7eb9612
commit
c5fb6da7e4
12 changed files with 126 additions and 112 deletions
|
|
@ -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 的世界书。
|
||||
- 删除失败的角色仍算使用者。判断在刷新列表之前完成。
|
||||
- 不检查聊天级世界书,也不检查读不出的角色卡。
|
||||
|
|
|
|||
|
|
@ -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;取消请求或正文读取会停止后续读取。
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "角色已删除,但世界书清理失败"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "角色已刪除,但世界書清理失敗"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue