From 061d5345dee9f2b52664684578d1be867fdefa4a Mon Sep 17 00:00:00 2001 From: Chizhen Zhang Date: Fri, 18 Sep 2026 02:08:29 +0800 Subject: [PATCH 1/5] =?UTF-8?q?refactor:=20=E8=A7=92=E8=89=B2=E5=8D=A1?= =?UTF-8?q?=E5=88=86=E6=AE=B5=E5=AD=97=E6=AE=B5=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CharacterRepository 读写完整的 personality/scenario/post_history_instructions 等字段 - 不再在导入时有损拼接,保留 SillyTavern 原始结构 - 支持 alternate_greetings/mes_example/creator_notes/character_version - 为后续导出和分段 Prompt 组装打好地基 --- .../javatavern/data/CharacterRepository.java | 82 +++++++++++++++++-- .../zcz/javatavern/data/TavernDatabase.java | 60 +++++++++++++- .../importer/CharacterCardParser.java | 39 +++++---- .../javatavern/model/CharacterCardData.java | 73 +++++++++++++++++ .../javatavern/model/CharacterProfile.java | 63 ++++++++++++++ 5 files changed, 290 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/zcz/javatavern/data/CharacterRepository.java b/app/src/main/java/com/zcz/javatavern/data/CharacterRepository.java index 843b929..143ca4d 100644 --- a/app/src/main/java/com/zcz/javatavern/data/CharacterRepository.java +++ b/app/src/main/java/com/zcz/javatavern/data/CharacterRepository.java @@ -33,7 +33,12 @@ public List getCharacters() { List characters = new ArrayList<>(); try (Cursor cursor = database.getReadableDatabase().query( TavernDatabase.TABLE_CHARACTERS, - new String[]{"id", "name", "description", "greeting", "system_prompt", "accent_color", "avatar"}, + new String[]{ + "id", "name", "description", "personality", "scenario", "greeting", + "system_prompt", "post_history_instructions", "creator_notes", + "character_version", "mes_example", "alternate_greetings_json", + "accent_color", "avatar" + }, null, null, null, @@ -51,7 +56,12 @@ public List getCharacters() { public CharacterProfile findById(String id) { try (Cursor cursor = database.getReadableDatabase().query( TavernDatabase.TABLE_CHARACTERS, - new String[]{"id", "name", "description", "greeting", "system_prompt", "accent_color", "avatar"}, + new String[]{ + "id", "name", "description", "personality", "scenario", "greeting", + "system_prompt", "post_history_instructions", "creator_notes", + "character_version", "mes_example", "alternate_greetings_json", + "accent_color", "avatar" + }, "id = ?", new String[]{id}, null, @@ -90,8 +100,16 @@ public CharacterProfile importCard(CharacterCardData card) { characterValues.put("id", characterId); characterValues.put("name", card.getName()); characterValues.put("description", card.getDescription()); + characterValues.put("personality", card.getPersonality()); + characterValues.put("scenario", card.getScenario()); characterValues.put("greeting", card.getGreeting()); characterValues.put("system_prompt", card.getSystemPrompt()); + characterValues.put("post_history_instructions", card.getPostHistoryInstructions()); + characterValues.put("creator_notes", card.getCreatorNotes()); + characterValues.put("character_version", card.getCharacterVersion()); + characterValues.put("mes_example", card.getMesExample()); + characterValues.put("alternate_greetings_json", + new JSONArray(card.getAlternateGreetings()).toString()); characterValues.put("accent_color", accentColor); characterValues.put("source_hash", card.getSourceHash()); characterValues.put("avatar", card.getAvatar() == null ? "" : card.getAvatar()); @@ -107,9 +125,16 @@ public CharacterProfile importCard(CharacterCardData card) { characterId, card.getName(), card.getDescription(), + card.getPersonality(), + card.getScenario(), card.getGreeting(), accentColor, card.getSystemPrompt(), + card.getPostHistoryInstructions(), + card.getCreatorNotes(), + card.getCharacterVersion(), + card.getMesExample(), + card.getAlternateGreetings(), card.getAvatar() == null ? "" : card.getAvatar(), card.getWorldEntries() ); @@ -130,8 +155,15 @@ public CharacterProfile createCharacter( values.put("id", characterId); values.put("name", name); values.put("description", description); + values.put("personality", ""); + values.put("scenario", ""); values.put("greeting", greeting); values.put("system_prompt", systemPrompt); + values.put("post_history_instructions", ""); + values.put("creator_notes", ""); + values.put("character_version", ""); + values.put("mes_example", ""); + values.put("alternate_greetings_json", "[]"); values.put("accent_color", accentColor); values.put("source_hash", characterId); values.put("avatar", ""); @@ -141,10 +173,17 @@ public CharacterProfile createCharacter( characterId, name, description, + "", + "", greeting, accentColor, systemPrompt, "", + "", + "", + "", + List.of(), + "", List.of() ); } @@ -177,13 +216,20 @@ public CharacterProfile updateCharacter( private CharacterProfile readCharacter(Cursor cursor, List worldEntries) { return new CharacterProfile( - cursor.getString(0), - cursor.getString(1), - cursor.getString(2), - cursor.getString(3), - cursor.getInt(5), - cursor.getString(4), - cursor.getString(6), + cursor.getString(0), // id + cursor.getString(1), // name + cursor.getString(2), // description + cursor.getString(3), // personality + cursor.getString(4), // scenario + cursor.getString(5), // greeting + cursor.getInt(12), // accent_color + cursor.getString(6), // system_prompt + cursor.getString(7), // post_history_instructions + cursor.getString(8), // creator_notes + cursor.getString(9), // character_version + cursor.getString(10), // mes_example + parseAlternateGreetings(cursor.getString(11)), // alternate_greetings_json + cursor.getString(13), // avatar worldEntries ); } @@ -282,6 +328,24 @@ private List parseKeywords(String json) { return keywords; } + private List parseAlternateGreetings(String json) { + List greetings = new ArrayList<>(); + if (json == null || json.trim().isEmpty() || "[]".equals(json.trim())) { + return greetings; + } + try { + JSONArray array = new JSONArray(json); + for (int index = 0; index < array.length(); index++) { + String greeting = array.optString(index).trim(); + if (!greeting.isEmpty()) { + greetings.add(greeting); + } + } + } catch (JSONException ignored) { + } + return greetings; + } + private String findIdBySourceHash(SQLiteDatabase writable, String sourceHash) { try (Cursor cursor = writable.query( TavernDatabase.TABLE_CHARACTERS, diff --git a/app/src/main/java/com/zcz/javatavern/data/TavernDatabase.java b/app/src/main/java/com/zcz/javatavern/data/TavernDatabase.java index 303cdfe..8d8d4ff 100644 --- a/app/src/main/java/com/zcz/javatavern/data/TavernDatabase.java +++ b/app/src/main/java/com/zcz/javatavern/data/TavernDatabase.java @@ -25,7 +25,7 @@ public final class TavernDatabase extends SQLiteOpenHelper { private static final String TAG = "TavernDatabase"; private static final String DATABASE_NAME = "tavern.db"; - private static final int DATABASE_VERSION = 5; + private static final int DATABASE_VERSION = 6; public static final String TABLE_CHARACTERS = "characters"; public static final String TABLE_WORLD_ENTRIES = "world_entries"; @@ -35,6 +35,7 @@ public final class TavernDatabase extends SQLiteOpenHelper { public static final String TABLE_MESSAGES_FTS = "messages_fts"; public static final String TABLE_PRESETS = "presets"; public static final String TABLE_GROUPS = "groups"; + public static final String TABLE_PERSONAS = "personas"; @SuppressLint("StaticFieldLeak") private static volatile TavernDatabase instance; @@ -80,6 +81,7 @@ public void onCreate(SQLiteDatabase database) { createMessageVersionTable(database); createPresetTable(database); createGroupTable(database); + createPersonaTable(database); seedPresets(database); boolean migratedCharacters = migrateLegacyCharacters(database); migrateLegacyMessages(database); @@ -111,6 +113,36 @@ public void onUpgrade(SQLiteDatabase database, int oldVersion, int newVersion) { database.execSQL("ALTER TABLE " + TABLE_MESSAGES + " ADD COLUMN active_version INTEGER NOT NULL DEFAULT 1"); } + if (oldVersion < 6) { + upgradeToVersion6(database); + } + } + + /** + * SillyTavern-style segmented character fields (personality/scenario/ + * post_history_instructions/creator_notes/character_version/mes_example/ + * alternate_greetings), previously flattened into {@code system_prompt} + * at import time. Existing rows keep whatever their {@code system_prompt} + * already held — that text still renders an equivalent prompt — the new + * columns just start empty for them; only newly imported/edited + * characters populate the segmented fields going forward. + */ + private void upgradeToVersion6(SQLiteDatabase database) { + database.execSQL("ALTER TABLE " + TABLE_CHARACTERS + + " ADD COLUMN personality TEXT NOT NULL DEFAULT ''"); + database.execSQL("ALTER TABLE " + TABLE_CHARACTERS + + " ADD COLUMN scenario TEXT NOT NULL DEFAULT ''"); + database.execSQL("ALTER TABLE " + TABLE_CHARACTERS + + " ADD COLUMN post_history_instructions TEXT NOT NULL DEFAULT ''"); + database.execSQL("ALTER TABLE " + TABLE_CHARACTERS + + " ADD COLUMN creator_notes TEXT NOT NULL DEFAULT ''"); + database.execSQL("ALTER TABLE " + TABLE_CHARACTERS + + " ADD COLUMN character_version TEXT NOT NULL DEFAULT ''"); + database.execSQL("ALTER TABLE " + TABLE_CHARACTERS + + " ADD COLUMN mes_example TEXT NOT NULL DEFAULT ''"); + database.execSQL("ALTER TABLE " + TABLE_CHARACTERS + + " ADD COLUMN alternate_greetings_json TEXT NOT NULL DEFAULT '[]'"); + createPersonaTable(database); } private void upgradeToVersion2(SQLiteDatabase database) { @@ -137,8 +169,16 @@ private void createCharacterTables(SQLiteDatabase database) { "id TEXT PRIMARY KEY," + "name TEXT NOT NULL," + "description TEXT NOT NULL," + + "personality TEXT NOT NULL DEFAULT ''," + + "scenario TEXT NOT NULL DEFAULT ''," + "greeting TEXT NOT NULL," + "system_prompt TEXT NOT NULL," + + "post_history_instructions TEXT NOT NULL DEFAULT ''," + + "creator_notes TEXT NOT NULL DEFAULT ''," + + "character_version TEXT NOT NULL DEFAULT ''," + + "mes_example TEXT NOT NULL DEFAULT ''," + + "alternate_greetings_json TEXT NOT NULL DEFAULT '[]'," + + "persona_id TEXT," + "accent_color INTEGER NOT NULL," + "source_hash TEXT NOT NULL UNIQUE," + "avatar TEXT NOT NULL DEFAULT ''," + @@ -407,6 +447,24 @@ private void createGroupTable(SQLiteDatabase database) { ); } + /** + * User personas (SillyTavern-style): a name/description the user speaks + * as. {@code is_default} marks the one persona used when a character has + * no {@code characters.persona_id} override — at most one row should + * have it set, enforced in {@code PersonaRepository} rather than SQL so + * seeding/updates stay simple statements. + */ + private void createPersonaTable(SQLiteDatabase database) { + database.execSQL( + "CREATE TABLE IF NOT EXISTS " + TABLE_PERSONAS + " (" + + "id TEXT PRIMARY KEY," + + "name TEXT NOT NULL," + + "description TEXT NOT NULL DEFAULT ''," + + "is_default INTEGER NOT NULL DEFAULT 0," + + "created_at INTEGER NOT NULL)" + ); + } + private void seedPresets(SQLiteDatabase database) { for (GenerationPreset preset : GenerationPreset.builtInPresets()) { ContentValues values = new ContentValues(); diff --git a/app/src/main/java/com/zcz/javatavern/importer/CharacterCardParser.java b/app/src/main/java/com/zcz/javatavern/importer/CharacterCardParser.java index 1c47d74..542f719 100644 --- a/app/src/main/java/com/zcz/javatavern/importer/CharacterCardParser.java +++ b/app/src/main/java/com/zcz/javatavern/importer/CharacterCardParser.java @@ -27,7 +27,6 @@ public CharacterCardData parse(String json) throws JSONException { } String description = firstNonBlank( data.optString("description"), - data.optString("personality"), "导入的 AI 角色" ); String greeting = firstNonBlank( @@ -35,24 +34,37 @@ public CharacterCardData parse(String json) throws JSONException { data.optString("first_message"), "你好。" ); - String systemPrompt = buildSystemPrompt(data, description); return new CharacterCardData( name, description, + data.optString("personality").trim(), + data.optString("scenario").trim(), greeting, - systemPrompt, + data.optString("system_prompt").trim(), + data.optString("post_history_instructions").trim(), + data.optString("creator_notes").trim(), + data.optString("character_version").trim(), + data.optString("mes_example").trim(), + parseAlternateGreetings(data), sha256(json), + "", parseWorldEntries(data, root) ); } - private String buildSystemPrompt(JSONObject data, String description) { - List sections = new ArrayList<>(); - addSection(sections, "角色描述", description); - addSection(sections, "性格", data.optString("personality")); - addSection(sections, "场景", data.optString("scenario")); - addSection(sections, "角色规则", data.optString("system_prompt")); - return String.join("\n\n", sections); + private List parseAlternateGreetings(JSONObject data) { + JSONArray raw = data.optJSONArray("alternate_greetings"); + if (raw == null) { + return List.of(); + } + List greetings = new ArrayList<>(); + for (int index = 0; index < raw.length(); index++) { + String greeting = raw.optString(index).trim(); + if (!greeting.isEmpty()) { + greetings.add(greeting); + } + } + return greetings; } private List parseWorldEntries(JSONObject data, JSONObject root) { @@ -151,13 +163,6 @@ private void addKeyword(List keywords, String keyword) { } } - private void addSection(List sections, String title, String value) { - String normalized = value.trim(); - if (!normalized.isEmpty()) { - sections.add(title + ":" + normalized); - } - } - private String firstNonBlank(String... values) { for (String value : values) { String normalized = value == null ? "" : value.trim(); diff --git a/app/src/main/java/com/zcz/javatavern/model/CharacterCardData.java b/app/src/main/java/com/zcz/javatavern/model/CharacterCardData.java index 28ff9e9..0cd3cc7 100644 --- a/app/src/main/java/com/zcz/javatavern/model/CharacterCardData.java +++ b/app/src/main/java/com/zcz/javatavern/model/CharacterCardData.java @@ -2,11 +2,28 @@ import java.util.List; +/** + * Parsed character card, kept as SillyTavern-style separate fields rather + * than one concatenated block. + * + *

{@code systemPrompt} here is the card's own raw {@code system_prompt} + * field only (not description/personality/scenario glued together) — + * assembling those into the actual request prompt is + * {@link com.zcz.javatavern.network.OpenAiCompatibleClient}'s job, done at + * generation time, so the pieces stay independently editable/exportable. + */ public final class CharacterCardData { private final String name; private final String description; + private final String personality; + private final String scenario; private final String greeting; private final String systemPrompt; + private final String postHistoryInstructions; + private final String creatorNotes; + private final String characterVersion; + private final String mesExample; + private final List alternateGreetings; private final String sourceHash; private final String avatar; private final List worldEntries; @@ -14,21 +31,49 @@ public final class CharacterCardData { public CharacterCardData( String name, String description, + String personality, + String scenario, String greeting, String systemPrompt, + String postHistoryInstructions, + String creatorNotes, + String characterVersion, + String mesExample, + List alternateGreetings, String sourceHash, String avatar, List worldEntries ) { this.name = name; this.description = description; + this.personality = personality == null ? "" : personality; + this.scenario = scenario == null ? "" : scenario; this.greeting = greeting; this.systemPrompt = systemPrompt; + this.postHistoryInstructions = postHistoryInstructions == null ? "" : postHistoryInstructions; + this.creatorNotes = creatorNotes == null ? "" : creatorNotes; + this.characterVersion = characterVersion == null ? "" : characterVersion; + this.mesExample = mesExample == null ? "" : mesExample; + this.alternateGreetings = alternateGreetings == null ? List.of() : List.copyOf(alternateGreetings); this.sourceHash = sourceHash; this.avatar = avatar == null ? "" : avatar; this.worldEntries = List.copyOf(worldEntries); } + /** Compact form used where only the core fields are known (e.g. re-wrapping after an import filter). */ + public CharacterCardData( + String name, + String description, + String greeting, + String systemPrompt, + String sourceHash, + String avatar, + List worldEntries + ) { + this(name, description, "", "", greeting, systemPrompt, "", "", "", "", + List.of(), sourceHash, avatar, worldEntries); + } + public CharacterCardData( String name, String description, @@ -48,6 +93,14 @@ public String getDescription() { return description; } + public String getPersonality() { + return personality; + } + + public String getScenario() { + return scenario; + } + public String getGreeting() { return greeting; } @@ -56,6 +109,26 @@ public String getSystemPrompt() { return systemPrompt; } + public String getPostHistoryInstructions() { + return postHistoryInstructions; + } + + public String getCreatorNotes() { + return creatorNotes; + } + + public String getCharacterVersion() { + return characterVersion; + } + + public String getMesExample() { + return mesExample; + } + + public List getAlternateGreetings() { + return alternateGreetings; + } + public String getSourceHash() { return sourceHash; } diff --git a/app/src/main/java/com/zcz/javatavern/model/CharacterProfile.java b/app/src/main/java/com/zcz/javatavern/model/CharacterProfile.java index c2fd421..2161be6 100644 --- a/app/src/main/java/com/zcz/javatavern/model/CharacterProfile.java +++ b/app/src/main/java/com/zcz/javatavern/model/CharacterProfile.java @@ -6,9 +6,16 @@ public final class CharacterProfile { private final String id; private final String name; private final String description; + private final String personality; + private final String scenario; private final String greeting; private final int accentColor; private final String systemPrompt; + private final String postHistoryInstructions; + private final String creatorNotes; + private final String characterVersion; + private final String mesExample; + private final List alternateGreetings; private final String avatar; private final List worldEntries; @@ -16,22 +23,50 @@ public CharacterProfile( String id, String name, String description, + String personality, + String scenario, String greeting, int accentColor, String systemPrompt, + String postHistoryInstructions, + String creatorNotes, + String characterVersion, + String mesExample, + List alternateGreetings, String avatar, List worldEntries ) { this.id = id; this.name = name; this.description = description; + this.personality = personality == null ? "" : personality; + this.scenario = scenario == null ? "" : scenario; this.greeting = greeting; this.accentColor = accentColor; this.systemPrompt = systemPrompt; + this.postHistoryInstructions = postHistoryInstructions == null ? "" : postHistoryInstructions; + this.creatorNotes = creatorNotes == null ? "" : creatorNotes; + this.characterVersion = characterVersion == null ? "" : characterVersion; + this.mesExample = mesExample == null ? "" : mesExample; + this.alternateGreetings = alternateGreetings == null ? List.of() : List.copyOf(alternateGreetings); this.avatar = avatar == null ? "" : avatar; this.worldEntries = List.copyOf(worldEntries); } + public CharacterProfile( + String id, + String name, + String description, + String greeting, + int accentColor, + String systemPrompt, + String avatar, + List worldEntries + ) { + this(id, name, description, "", "", greeting, accentColor, systemPrompt, + "", "", "", "", List.of(), avatar, worldEntries); + } + public CharacterProfile( String id, String name, @@ -66,6 +101,14 @@ public String getDescription() { return description; } + public String getPersonality() { + return personality; + } + + public String getScenario() { + return scenario; + } + public String getGreeting() { return greeting; } @@ -78,6 +121,26 @@ public String getSystemPrompt() { return systemPrompt; } + public String getPostHistoryInstructions() { + return postHistoryInstructions; + } + + public String getCreatorNotes() { + return creatorNotes; + } + + public String getCharacterVersion() { + return characterVersion; + } + + public String getMesExample() { + return mesExample; + } + + public List getAlternateGreetings() { + return alternateGreetings; + } + /** * Avatar source marker. *

    From 29c6d5e369851871a9821da2c8d8fdde78a89a7a Mon Sep 17 00:00:00 2001 From: Chizhen Zhang Date: Fri, 18 Sep 2026 02:24:32 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20Persona=20=E5=AE=9E=E4=BD=93?= =?UTF-8?q?=E5=92=8C=20Repository?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Persona 模型类 - PersonaRepository 支持 CRUD、默认 Persona 切换 - 自动创建默认 Persona,且默认 Persona 不可删除 - 为后续 {{user}} 宏和用户人设注入打好地基 --- .../javatavern/data/PersonaRepository.java | 163 ++++++++++++++++++ .../com/zcz/javatavern/model/Persona.java | 37 ++++ 2 files changed, 200 insertions(+) create mode 100644 app/src/main/java/com/zcz/javatavern/data/PersonaRepository.java create mode 100644 app/src/main/java/com/zcz/javatavern/model/Persona.java diff --git a/app/src/main/java/com/zcz/javatavern/data/PersonaRepository.java b/app/src/main/java/com/zcz/javatavern/data/PersonaRepository.java new file mode 100644 index 0000000..82bbcf6 --- /dev/null +++ b/app/src/main/java/com/zcz/javatavern/data/PersonaRepository.java @@ -0,0 +1,163 @@ +package com.zcz.javatavern.data; + +import android.content.ContentValues; +import android.database.Cursor; +import com.zcz.javatavern.model.Persona; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public class PersonaRepository { + private final TavernDatabase database; + + public PersonaRepository(TavernDatabase database) { + this.database = database; + } + + public List getPersonas() { + List personas = new ArrayList<>(); + try (Cursor cursor = database.getReadableDatabase().query( + TavernDatabase.TABLE_PERSONAS, + new String[]{"id", "name", "description", "is_default", "avatar"}, + null, + null, + null, + null, + "created_at ASC" + )) { + while (cursor.moveToNext()) { + personas.add(new Persona( + cursor.getString(0), + cursor.getString(1), + cursor.getString(2), + cursor.getInt(3) == 1, + cursor.getString(4) + )); + } + } + return personas; + } + + public Persona getDefaultPersona() { + try (Cursor cursor = database.getReadableDatabase().query( + TavernDatabase.TABLE_PERSONAS, + new String[]{"id", "name", "description", "is_default", "avatar"}, + "is_default = 1", + null, + null, + null, + null, + "1" + )) { + if (cursor.moveToFirst()) { + return new Persona( + cursor.getString(0), + cursor.getString(1), + cursor.getString(2), + true, + cursor.getString(4) + ); + } + } + return createDefaultPersona(); + } + + private Persona createDefaultPersona() { + String personaId = "default-" + UUID.randomUUID(); + ContentValues values = new ContentValues(); + values.put("id", personaId); + values.put("name", "默认用户"); + values.put("description", ""); + values.put("is_default", 1); + values.put("avatar", ""); + values.put("created_at", System.currentTimeMillis()); + database.getWritableDatabase().insertOrThrow(TavernDatabase.TABLE_PERSONAS, null, values); + return new Persona(personaId, "默认用户", "", true, ""); + } + + public Persona createPersona(String name, String description) { + String personaId = "persona-" + UUID.randomUUID(); + ContentValues values = new ContentValues(); + values.put("id", personaId); + values.put("name", name); + values.put("description", description); + values.put("is_default", 0); + values.put("avatar", ""); + values.put("created_at", System.currentTimeMillis()); + database.getWritableDatabase().insertOrThrow(TavernDatabase.TABLE_PERSONAS, null, values); + return new Persona(personaId, name, description, false, ""); + } + + public void setDefaultPersona(String personaId) { + database.getWritableDatabase().beginTransaction(); + try { + ContentValues resetValues = new ContentValues(); + resetValues.put("is_default", 0); + database.getWritableDatabase().update( + TavernDatabase.TABLE_PERSONAS, + resetValues, + null, + null + ); + ContentValues setValues = new ContentValues(); + setValues.put("is_default", 1); + database.getWritableDatabase().update( + TavernDatabase.TABLE_PERSONAS, + setValues, + "id = ?", + new String[]{personaId} + ); + database.getWritableDatabase().setTransactionSuccessful(); + } finally { + database.getWritableDatabase().endTransaction(); + } + } + + public void deletePersona(String personaId) { + Persona persona = findById(personaId); + if (persona != null && persona.isDefault()) { + throw new IllegalStateException("Cannot delete default persona"); + } + database.getWritableDatabase().delete( + TavernDatabase.TABLE_PERSONAS, + "id = ?", + new String[]{personaId} + ); + } + + public Persona findById(String personaId) { + try (Cursor cursor = database.getReadableDatabase().query( + TavernDatabase.TABLE_PERSONAS, + new String[]{"id", "name", "description", "is_default", "avatar"}, + "id = ?", + new String[]{personaId}, + null, + null, + null, + "1" + )) { + if (cursor.moveToFirst()) { + return new Persona( + cursor.getString(0), + cursor.getString(1), + cursor.getString(2), + cursor.getInt(3) == 1, + cursor.getString(4) + ); + } + } + return null; + } + + public void updatePersona(String personaId, String name, String description) { + ContentValues values = new ContentValues(); + values.put("name", name); + values.put("description", description); + database.getWritableDatabase().update( + TavernDatabase.TABLE_PERSONAS, + values, + "id = ?", + new String[]{personaId} + ); + } +} diff --git a/app/src/main/java/com/zcz/javatavern/model/Persona.java b/app/src/main/java/com/zcz/javatavern/model/Persona.java new file mode 100644 index 0000000..fd1530d --- /dev/null +++ b/app/src/main/java/com/zcz/javatavern/model/Persona.java @@ -0,0 +1,37 @@ +package com.zcz.javatavern.model; + +public class Persona { + private final String id; + private final String name; + private final String description; + private final boolean isDefault; + private final String avatar; + + public Persona(String id, String name, String description, boolean isDefault, String avatar) { + this.id = id; + this.name = name; + this.description = description; + this.isDefault = isDefault; + this.avatar = avatar; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public boolean isDefault() { + return isDefault; + } + + public String getAvatar() { + return avatar; + } +} From 98357b8e267d261b562d7c17e28e3710f4af43de Mon Sep 17 00:00:00 2001 From: Chizhen Zhang Date: Fri, 18 Sep 2026 02:45:55 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=E4=B8=96=E7=95=8C=E4=B9=A6?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E8=A1=A5=E5=85=A8=E5=AF=B9=E9=BD=90=20SillyT?= =?UTF-8?q?avern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增次要关键词(secondary_keys)、扫描深度(scan_depth) - 支持大小写敏感、整词匹配、互斥组(automation_id) - 角色类型(role: system/user/assistant) - 粘性(sticky)和冷却(cooldown)机制 - 向量化标记(vectorized)预留语义搜索接口 - Database schema 升级到 v7 - 为高级世界书激活算法打好地基 --- .../javatavern/data/CharacterRepository.java | 50 +++++-- .../zcz/javatavern/data/TavernDatabase.java | 42 +++++- .../zcz/javatavern/model/WorldBookEntry.java | 133 ++++++++++++++++++ 3 files changed, 210 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/zcz/javatavern/data/CharacterRepository.java b/app/src/main/java/com/zcz/javatavern/data/CharacterRepository.java index 143ca4d..4c0f0fd 100644 --- a/app/src/main/java/com/zcz/javatavern/data/CharacterRepository.java +++ b/app/src/main/java/com/zcz/javatavern/data/CharacterRepository.java @@ -239,8 +239,10 @@ private List loadWorldEntries(String characterId) { try (Cursor cursor = database.getReadableDatabase().query( TavernDatabase.TABLE_WORLD_ENTRIES, new String[]{ - "id", "keywords_json", "content", "enabled", "constant_entry", - "position", "sort_order", "priority", "depth", "probability", + "id", "keywords_json", "secondary_keys_json", "content", "enabled", "constant_entry", + "position", "sort_order", "priority", "depth", "scan_depth", + "case_sensitive", "match_whole_words", "use_group_scoring", "automation_id", + "role", "vectorized", "sticky", "cooldown", "probability", "exclude_recursion", "prevent_recursion" }, "character_id = ?", @@ -251,18 +253,28 @@ private List loadWorldEntries(String characterId) { )) { while (cursor.moveToNext()) { entries.add(new WorldBookEntry( - cursor.getLong(0), - parseKeywords(cursor.getString(1)), - cursor.getString(2), - cursor.getInt(3) == 1, - cursor.getInt(4) == 1, - cursor.getInt(5), - cursor.getInt(6), - cursor.getInt(7), - cursor.getInt(8), - cursor.getInt(9), - cursor.getInt(10) == 1, - cursor.getInt(11) == 1 + cursor.getLong(0), // id + parseKeywords(cursor.getString(1)), // keywords_json + parseKeywords(cursor.getString(2)), // secondary_keys_json + cursor.getString(3), // content + cursor.getInt(4) == 1, // enabled + cursor.getInt(5) == 1, // constant_entry + cursor.getInt(6), // position + cursor.getInt(7), // sort_order + cursor.getInt(8), // priority + cursor.getInt(9), // depth + cursor.getInt(10), // scan_depth + cursor.getInt(11) == 1, // case_sensitive + cursor.getInt(12) == 1, // match_whole_words + cursor.getInt(13) == 1, // use_group_scoring + cursor.getString(14), // automation_id + cursor.getString(15), // role + cursor.getInt(16) == 1, // vectorized + cursor.getInt(17), // sticky + cursor.getInt(18), // cooldown + cursor.getInt(19), // probability + cursor.getInt(20) == 1, // exclude_recursion + cursor.getInt(21) == 1 // prevent_recursion )); } } @@ -300,6 +312,7 @@ private ContentValues worldEntryValues(String characterId, WorldBookEntry entry) values.put("character_id", characterId); } values.put("keywords_json", new JSONArray(entry.getKeywords()).toString()); + values.put("secondary_keys_json", new JSONArray(entry.getSecondaryKeys()).toString()); values.put("content", entry.getContent()); values.put("enabled", entry.isEnabled() ? 1 : 0); values.put("constant_entry", entry.isConstant() ? 1 : 0); @@ -307,6 +320,15 @@ private ContentValues worldEntryValues(String characterId, WorldBookEntry entry) values.put("sort_order", entry.getOrder()); values.put("priority", entry.getPriority()); values.put("depth", entry.getDepth()); + values.put("scan_depth", entry.getScanDepth()); + values.put("case_sensitive", entry.isCaseSensitive() ? 1 : 0); + values.put("match_whole_words", entry.isMatchWholeWords() ? 1 : 0); + values.put("use_group_scoring", entry.isUseGroupScoring() ? 1 : 0); + values.put("automation_id", entry.getAutomationId()); + values.put("role", entry.getRole()); + values.put("vectorized", entry.isVectorized() ? 1 : 0); + values.put("sticky", entry.getSticky()); + values.put("cooldown", entry.getCooldown()); values.put("probability", entry.getProbability()); values.put("exclude_recursion", entry.isExcludeRecursion() ? 1 : 0); values.put("prevent_recursion", entry.isPreventRecursion() ? 1 : 0); diff --git a/app/src/main/java/com/zcz/javatavern/data/TavernDatabase.java b/app/src/main/java/com/zcz/javatavern/data/TavernDatabase.java index 8d8d4ff..fd32e7a 100644 --- a/app/src/main/java/com/zcz/javatavern/data/TavernDatabase.java +++ b/app/src/main/java/com/zcz/javatavern/data/TavernDatabase.java @@ -25,7 +25,7 @@ public final class TavernDatabase extends SQLiteOpenHelper { private static final String TAG = "TavernDatabase"; private static final String DATABASE_NAME = "tavern.db"; - private static final int DATABASE_VERSION = 6; + private static final int DATABASE_VERSION = 7; public static final String TABLE_CHARACTERS = "characters"; public static final String TABLE_WORLD_ENTRIES = "world_entries"; @@ -116,6 +116,9 @@ public void onUpgrade(SQLiteDatabase database, int oldVersion, int newVersion) { if (oldVersion < 6) { upgradeToVersion6(database); } + if (oldVersion < 7) { + upgradeToVersion7(database); + } } /** @@ -145,6 +148,33 @@ private void upgradeToVersion6(SQLiteDatabase database) { createPersonaTable(database); } + /** + * SillyTavern 世界书完整字段对齐:次要关键词、扫描深度、大小写/整词匹配、 + * 互斥组、角色类型、sticky/cooldown、向量化标记等 + */ + private void upgradeToVersion7(SQLiteDatabase database) { + database.execSQL("ALTER TABLE " + TABLE_WORLD_ENTRIES + + " ADD COLUMN secondary_keys_json TEXT NOT NULL DEFAULT '[]'"); + database.execSQL("ALTER TABLE " + TABLE_WORLD_ENTRIES + + " ADD COLUMN scan_depth INTEGER NOT NULL DEFAULT 100"); + database.execSQL("ALTER TABLE " + TABLE_WORLD_ENTRIES + + " ADD COLUMN case_sensitive INTEGER NOT NULL DEFAULT 0"); + database.execSQL("ALTER TABLE " + TABLE_WORLD_ENTRIES + + " ADD COLUMN match_whole_words INTEGER NOT NULL DEFAULT 0"); + database.execSQL("ALTER TABLE " + TABLE_WORLD_ENTRIES + + " ADD COLUMN use_group_scoring INTEGER NOT NULL DEFAULT 0"); + database.execSQL("ALTER TABLE " + TABLE_WORLD_ENTRIES + + " ADD COLUMN automation_id TEXT NOT NULL DEFAULT ''"); + database.execSQL("ALTER TABLE " + TABLE_WORLD_ENTRIES + + " ADD COLUMN role TEXT NOT NULL DEFAULT 'system'"); + database.execSQL("ALTER TABLE " + TABLE_WORLD_ENTRIES + + " ADD COLUMN vectorized INTEGER NOT NULL DEFAULT 0"); + database.execSQL("ALTER TABLE " + TABLE_WORLD_ENTRIES + + " ADD COLUMN sticky INTEGER NOT NULL DEFAULT 0"); + database.execSQL("ALTER TABLE " + TABLE_WORLD_ENTRIES + + " ADD COLUMN cooldown INTEGER NOT NULL DEFAULT 0"); + } + private void upgradeToVersion2(SQLiteDatabase database) { // 世界书高级字段:position 列已存在但旧版本存的是列表索引(语义错误), // 本次补充其余字段并把 position 重置为 after_char,保持既有"拼在角色卡后"行为。 @@ -189,6 +219,7 @@ private void createCharacterTables(SQLiteDatabase database) { "id INTEGER PRIMARY KEY AUTOINCREMENT," + "character_id TEXT NOT NULL," + "keywords_json TEXT NOT NULL," + + "secondary_keys_json TEXT NOT NULL DEFAULT '[]'," + "content TEXT NOT NULL," + "enabled INTEGER NOT NULL," + "constant_entry INTEGER NOT NULL," + @@ -196,6 +227,15 @@ private void createCharacterTables(SQLiteDatabase database) { "sort_order INTEGER NOT NULL DEFAULT 100," + "priority INTEGER NOT NULL DEFAULT 0," + "depth INTEGER NOT NULL DEFAULT 4," + + "scan_depth INTEGER NOT NULL DEFAULT 100," + + "case_sensitive INTEGER NOT NULL DEFAULT 0," + + "match_whole_words INTEGER NOT NULL DEFAULT 0," + + "use_group_scoring INTEGER NOT NULL DEFAULT 0," + + "automation_id TEXT NOT NULL DEFAULT ''," + + "role TEXT NOT NULL DEFAULT 'system'," + + "vectorized INTEGER NOT NULL DEFAULT 0," + + "sticky INTEGER NOT NULL DEFAULT 0," + + "cooldown INTEGER NOT NULL DEFAULT 0," + "probability INTEGER NOT NULL DEFAULT 100," + "exclude_recursion INTEGER NOT NULL DEFAULT 0," + "prevent_recursion INTEGER NOT NULL DEFAULT 0," + diff --git a/app/src/main/java/com/zcz/javatavern/model/WorldBookEntry.java b/app/src/main/java/com/zcz/javatavern/model/WorldBookEntry.java index ab412ac..b549189 100644 --- a/app/src/main/java/com/zcz/javatavern/model/WorldBookEntry.java +++ b/app/src/main/java/com/zcz/javatavern/model/WorldBookEntry.java @@ -13,7 +13,17 @@ *
  • {@code order}:即 ST 的 {@code insertion_order},同位置内升序,默认 100。
  • *
  • {@code priority}:作者推荐优先级,0–99,同 order 时大者靠前,默认 0。
  • *
  • {@code depth}:注入深度(position=@D 时用),默认 4,本轮仅存储保留。
  • + *
  • {@code scanDepth}:向前扫描消息数量,默认 100。
  • *
  • {@code probability}:触发概率百分比,100=总是触发,0=永不触发,默认 100。
  • + *
  • {@code secondaryKeys}:次要关键词列表,命中任一即激活。
  • + *
  • {@code caseSensitive}:关键词匹配是否区分大小写。
  • + *
  • {@code matchWholeWords}:是否仅整词匹配。
  • + *
  • {@code useGroupScoring}:使用互斥组评分算法。
  • + *
  • {@code automationId}:互斥组 ID,同组仅最高分条目激活。
  • + *
  • {@code role}:消息角色(system/user/assistant)。
  • + *
  • {@code vectorized}:是否已向量化(预留给语义搜索)。
  • + *
  • {@code sticky}:粘性,连续激活轮数(0=不粘性)。
  • + *
  • {@code cooldown}:冷却,休息轮数(0=无冷却)。
  • *
  • {@code excludeRecursion}:递归扫描时不因其它条目命中而激活本条目。
  • *
  • {@code preventRecursion}:本条目 content 不作为递归扫描的匹配文本。
  • *
@@ -23,10 +33,13 @@ public final class WorldBookEntry { public static final int POSITION_AFTER_CHAR = 1; public static final int DEFAULT_ORDER = 100; public static final int DEFAULT_DEPTH = 4; + public static final int DEFAULT_SCAN_DEPTH = 100; public static final int DEFAULT_PROBABILITY = 100; + public static final String DEFAULT_ROLE = "system"; private final long id; private final List keywords; + private final List secondaryKeys; private final String content; private final boolean enabled; private final boolean constant; @@ -34,6 +47,15 @@ public final class WorldBookEntry { private final int order; private final int priority; private final int depth; + private final int scanDepth; + private final boolean caseSensitive; + private final boolean matchWholeWords; + private final boolean useGroupScoring; + private final String automationId; + private final String role; + private final boolean vectorized; + private final int sticky; + private final int cooldown; private final int probability; private final boolean excludeRecursion; private final boolean preventRecursion; @@ -76,6 +98,7 @@ public WorldBookEntry( this( 0L, keywords, + List.of(), content, enabled, constant, @@ -83,12 +106,22 @@ public WorldBookEntry( order, priority, depth, + DEFAULT_SCAN_DEPTH, + false, + false, + false, + "", + DEFAULT_ROLE, + false, + 0, + 0, probability, excludeRecursion, preventRecursion ); } + /** 兼容 UI 层:包含 id 但没有新字段 */ public WorldBookEntry( long id, List keywords, @@ -102,9 +135,60 @@ public WorldBookEntry( int probability, boolean excludeRecursion, boolean preventRecursion + ) { + this( + id, + keywords, + List.of(), + content, + enabled, + constant, + position, + order, + priority, + depth, + DEFAULT_SCAN_DEPTH, + false, + false, + false, + "", + DEFAULT_ROLE, + false, + 0, + 0, + probability, + excludeRecursion, + preventRecursion + ); + } + + public WorldBookEntry( + long id, + List keywords, + List secondaryKeys, + String content, + boolean enabled, + boolean constant, + int position, + int order, + int priority, + int depth, + int scanDepth, + boolean caseSensitive, + boolean matchWholeWords, + boolean useGroupScoring, + String automationId, + String role, + boolean vectorized, + int sticky, + int cooldown, + int probability, + boolean excludeRecursion, + boolean preventRecursion ) { this.id = id; this.keywords = List.copyOf(keywords); + this.secondaryKeys = List.copyOf(secondaryKeys); this.content = content; this.enabled = enabled; this.constant = constant; @@ -112,6 +196,15 @@ public WorldBookEntry( this.order = order; this.priority = priority; this.depth = depth; + this.scanDepth = scanDepth; + this.caseSensitive = caseSensitive; + this.matchWholeWords = matchWholeWords; + this.useGroupScoring = useGroupScoring; + this.automationId = automationId; + this.role = role; + this.vectorized = vectorized; + this.sticky = sticky; + this.cooldown = cooldown; this.probability = probability; this.excludeRecursion = excludeRecursion; this.preventRecursion = preventRecursion; @@ -126,6 +219,10 @@ public List getKeywords() { return keywords; } + public List getSecondaryKeys() { + return secondaryKeys; + } + public String getContent() { return content; } @@ -154,6 +251,42 @@ public int getDepth() { return depth; } + public int getScanDepth() { + return scanDepth; + } + + public boolean isCaseSensitive() { + return caseSensitive; + } + + public boolean isMatchWholeWords() { + return matchWholeWords; + } + + public boolean isUseGroupScoring() { + return useGroupScoring; + } + + public String getAutomationId() { + return automationId; + } + + public String getRole() { + return role; + } + + public boolean isVectorized() { + return vectorized; + } + + public int getSticky() { + return sticky; + } + + public int getCooldown() { + return cooldown; + } + public int getProbability() { return probability; } From 5d86b56a67a20eb48f049b9b9a86be3d26cf2ac5 Mon Sep 17 00:00:00 2001 From: Chizhen Zhang Date: Fri, 18 Sep 2026 10:28:36 +0800 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=E4=B8=80=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E5=A4=9A=E8=81=8A=E5=A4=A9=E6=95=B0=E6=8D=AE=E5=B1=82=EF=BC=88?= =?UTF-8?q?Phase=200.4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Database schema 升级到 v8 - 消息表新增 chat_id 字段,支持同一角色多个独立聊天 - 新增 chats 表存储聊天会话元数据 - ChatSessionRepository 管理聊天会话 CRUD - Chat 模型类 - 自动迁移:既有消息归入默认聊天,保持向后兼容 - UI 层暂不接入,避免赶工偷工减料 --- .../data/ChatSessionRepository.java | 142 ++++++++++++++++++ .../zcz/javatavern/data/TavernDatabase.java | 49 +++++- .../java/com/zcz/javatavern/model/Chat.java | 31 ++++ 3 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/zcz/javatavern/data/ChatSessionRepository.java create mode 100644 app/src/main/java/com/zcz/javatavern/model/Chat.java diff --git a/app/src/main/java/com/zcz/javatavern/data/ChatSessionRepository.java b/app/src/main/java/com/zcz/javatavern/data/ChatSessionRepository.java new file mode 100644 index 0000000..48676fe --- /dev/null +++ b/app/src/main/java/com/zcz/javatavern/data/ChatSessionRepository.java @@ -0,0 +1,142 @@ +package com.zcz.javatavern.data; + +import android.content.ContentValues; +import android.database.Cursor; +import com.zcz.javatavern.model.Chat; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public class ChatSessionRepository { + private final TavernDatabase database; + + public ChatSessionRepository(TavernDatabase database) { + this.database = database; + } + + public List getChats(String characterId) { + List chats = new ArrayList<>(); + try (Cursor cursor = database.getReadableDatabase().query( + TavernDatabase.TABLE_CHATS, + new String[]{"id", "character_id", "name", "created_at"}, + "character_id = ?", + new String[]{characterId}, + null, + null, + "created_at DESC" + )) { + while (cursor.moveToNext()) { + chats.add(new Chat( + cursor.getString(0), + cursor.getString(1), + cursor.getString(2), + cursor.getLong(3) + )); + } + } + return chats; + } + + public Chat getDefaultChat(String characterId) { + String defaultChatId = "default-" + characterId; + try (Cursor cursor = database.getReadableDatabase().query( + TavernDatabase.TABLE_CHATS, + new String[]{"id", "character_id", "name", "created_at"}, + "id = ?", + new String[]{defaultChatId}, + null, + null, + null, + "1" + )) { + if (cursor.moveToFirst()) { + return new Chat( + cursor.getString(0), + cursor.getString(1), + cursor.getString(2), + cursor.getLong(3) + ); + } + } + return createDefaultChat(characterId); + } + + private Chat createDefaultChat(String characterId) { + String chatId = "default-" + characterId; + ContentValues values = new ContentValues(); + values.put("id", chatId); + values.put("character_id", characterId); + values.put("name", "默认聊天"); + values.put("created_at", System.currentTimeMillis()); + database.getWritableDatabase().insertOrThrow(TavernDatabase.TABLE_CHATS, null, values); + return new Chat(chatId, characterId, "默认聊天", System.currentTimeMillis()); + } + + public Chat createChat(String characterId, String name) { + String chatId = "chat-" + UUID.randomUUID(); + ContentValues values = new ContentValues(); + values.put("id", chatId); + values.put("character_id", characterId); + values.put("name", name); + long createdAt = System.currentTimeMillis(); + values.put("created_at", createdAt); + database.getWritableDatabase().insertOrThrow(TavernDatabase.TABLE_CHATS, null, values); + return new Chat(chatId, characterId, name, createdAt); + } + + public void renameChat(String chatId, String newName) { + ContentValues values = new ContentValues(); + values.put("name", newName); + database.getWritableDatabase().update( + TavernDatabase.TABLE_CHATS, + values, + "id = ?", + new String[]{chatId} + ); + } + + public void deleteChat(String chatId) { + if (chatId.startsWith("default-")) { + throw new IllegalStateException("Cannot delete default chat"); + } + database.getWritableDatabase().beginTransaction(); + try { + database.getWritableDatabase().delete( + TavernDatabase.TABLE_MESSAGES, + "chat_id = ?", + new String[]{chatId} + ); + database.getWritableDatabase().delete( + TavernDatabase.TABLE_CHATS, + "id = ?", + new String[]{chatId} + ); + database.getWritableDatabase().setTransactionSuccessful(); + } finally { + database.getWritableDatabase().endTransaction(); + } + } + + public Chat findById(String chatId) { + try (Cursor cursor = database.getReadableDatabase().query( + TavernDatabase.TABLE_CHATS, + new String[]{"id", "character_id", "name", "created_at"}, + "id = ?", + new String[]{chatId}, + null, + null, + null, + "1" + )) { + if (cursor.moveToFirst()) { + return new Chat( + cursor.getString(0), + cursor.getString(1), + cursor.getString(2), + cursor.getLong(3) + ); + } + } + return null; + } +} diff --git a/app/src/main/java/com/zcz/javatavern/data/TavernDatabase.java b/app/src/main/java/com/zcz/javatavern/data/TavernDatabase.java index fd32e7a..09a1baa 100644 --- a/app/src/main/java/com/zcz/javatavern/data/TavernDatabase.java +++ b/app/src/main/java/com/zcz/javatavern/data/TavernDatabase.java @@ -25,7 +25,7 @@ public final class TavernDatabase extends SQLiteOpenHelper { private static final String TAG = "TavernDatabase"; private static final String DATABASE_NAME = "tavern.db"; - private static final int DATABASE_VERSION = 7; + private static final int DATABASE_VERSION = 8; public static final String TABLE_CHARACTERS = "characters"; public static final String TABLE_WORLD_ENTRIES = "world_entries"; @@ -36,6 +36,7 @@ public final class TavernDatabase extends SQLiteOpenHelper { public static final String TABLE_PRESETS = "presets"; public static final String TABLE_GROUPS = "groups"; public static final String TABLE_PERSONAS = "personas"; + public static final String TABLE_CHATS = "chats"; @SuppressLint("StaticFieldLeak") private static volatile TavernDatabase instance; @@ -78,6 +79,7 @@ private TavernDatabase(Context context) { public void onCreate(SQLiteDatabase database) { createCharacterTables(database); createMessageTables(database); + createChatTable(database); createMessageVersionTable(database); createPresetTable(database); createGroupTable(database); @@ -175,6 +177,36 @@ private void upgradeToVersion7(SQLiteDatabase database) { " ADD COLUMN cooldown INTEGER NOT NULL DEFAULT 0"); } + /** + * 一角色多聊天:消息表增加 chat_id、chats 表存聊天元数据。 + * 迁移时将所有既有消息归入"默认聊天",保持向后兼容。 + */ + private void upgradeToVersion8(SQLiteDatabase database) { + database.execSQL("ALTER TABLE " + TABLE_MESSAGES + + " ADD COLUMN chat_id TEXT NOT NULL DEFAULT 'default'"); + database.execSQL( + "CREATE TABLE " + TABLE_CHATS + " (" + + "id TEXT PRIMARY KEY," + + "character_id TEXT NOT NULL," + + "name TEXT NOT NULL," + + "created_at INTEGER NOT NULL," + + "FOREIGN KEY(character_id) REFERENCES " + TABLE_CHARACTERS + "(id) ON DELETE CASCADE)" + ); + database.execSQL( + "CREATE INDEX index_chats_character ON " + TABLE_CHATS + "(character_id)" + ); + // 为所有有消息的角色创建默认聊天 + database.execSQL( + "INSERT INTO " + TABLE_CHATS + " (id, character_id, name, created_at) " + + "SELECT 'default-' || character_id, character_id, '默认聊天', MIN(created_at) " + + "FROM " + TABLE_MESSAGES + " GROUP BY character_id" + ); + // 将所有既有消息关联到默认聊天 + database.execSQL( + "UPDATE " + TABLE_MESSAGES + " SET chat_id = 'default-' || character_id" + ); + } + private void upgradeToVersion2(SQLiteDatabase database) { // 世界书高级字段:position 列已存在但旧版本存的是列表索引(语义错误), // 本次补充其余字段并把 position 重置为 after_char,保持既有"拼在角色卡后"行为。 @@ -501,10 +533,25 @@ private void createPersonaTable(SQLiteDatabase database) { "name TEXT NOT NULL," + "description TEXT NOT NULL DEFAULT ''," + "is_default INTEGER NOT NULL DEFAULT 0," + + "avatar TEXT NOT NULL DEFAULT ''," + "created_at INTEGER NOT NULL)" ); } + private void createChatTable(SQLiteDatabase database) { + database.execSQL( + "CREATE TABLE IF NOT EXISTS " + TABLE_CHATS + " (" + + "id TEXT PRIMARY KEY," + + "character_id TEXT NOT NULL," + + "name TEXT NOT NULL," + + "created_at INTEGER NOT NULL," + + "FOREIGN KEY(character_id) REFERENCES " + TABLE_CHARACTERS + "(id) ON DELETE CASCADE)" + ); + database.execSQL( + "CREATE INDEX IF NOT EXISTS index_chats_character ON " + TABLE_CHATS + "(character_id)" + ); + } + private void seedPresets(SQLiteDatabase database) { for (GenerationPreset preset : GenerationPreset.builtInPresets()) { ContentValues values = new ContentValues(); diff --git a/app/src/main/java/com/zcz/javatavern/model/Chat.java b/app/src/main/java/com/zcz/javatavern/model/Chat.java new file mode 100644 index 0000000..b89544b --- /dev/null +++ b/app/src/main/java/com/zcz/javatavern/model/Chat.java @@ -0,0 +1,31 @@ +package com.zcz.javatavern.model; + +public class Chat { + private final String id; + private final String characterId; + private final String name; + private final long createdAt; + + public Chat(String id, String characterId, String name, long createdAt) { + this.id = id; + this.characterId = characterId; + this.name = name; + this.createdAt = createdAt; + } + + public String getId() { + return id; + } + + public String getCharacterId() { + return characterId; + } + + public String getName() { + return name; + } + + public long getCreatedAt() { + return createdAt; + } +} From f96840dadfd07cfb9b4acc115a40905188f97d3b Mon Sep 17 00:00:00 2001 From: Chizhen Zhang Date: Fri, 18 Sep 2026 10:57:27 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=E8=A1=A5=E4=B8=8A=20upgradeToVersio?= =?UTF-8?q?n8=20=E7=9A=84=20onUpgrade=20=E8=B0=83=E7=94=A8=E5=92=8C=20onCr?= =?UTF-8?q?eate=20=E7=BC=BA=E5=A4=B1=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit upgradeToVersion8() 已完整实现(chats 表 + messages.chat_id + 默认聊天迁 移),但 onUpgrade() 里漏了 if (oldVersion < 8) 分支,从未被真正调用; 同时 onCreate() 的 createMessageTables() 建表语句本身也没有 chat_id 列。两者叠加导致新装和升级都会在触碰 messages.chat_id 的查询上抛 SQLiteException: no such column。 补充回归测试 upgradeFromVersion7_addsChatIdAndMigratesExistingMessages ToDefaultChat,种一个 v7 库并触发真实的 onUpgrade(db, 7, 8)。 Co-Authored-By: Claude Sonnet 5 --- .../zcz/javatavern/data/TavernDatabase.java | 4 + .../javatavern/data/TavernDatabaseTest.java | 171 ++++++++++++++++++ 2 files changed, 175 insertions(+) diff --git a/app/src/main/java/com/zcz/javatavern/data/TavernDatabase.java b/app/src/main/java/com/zcz/javatavern/data/TavernDatabase.java index 09a1baa..4c2a645 100644 --- a/app/src/main/java/com/zcz/javatavern/data/TavernDatabase.java +++ b/app/src/main/java/com/zcz/javatavern/data/TavernDatabase.java @@ -121,6 +121,9 @@ public void onUpgrade(SQLiteDatabase database, int oldVersion, int newVersion) { if (oldVersion < 7) { upgradeToVersion7(database); } + if (oldVersion < 8) { + upgradeToVersion8(database); + } } /** @@ -299,6 +302,7 @@ private void createMessageTables(SQLiteDatabase database) { "speaker_name TEXT NOT NULL DEFAULT ''," + "version_count INTEGER NOT NULL DEFAULT 1," + "active_version INTEGER NOT NULL DEFAULT 1," + + "chat_id TEXT NOT NULL DEFAULT 'default'," + "content TEXT NOT NULL," + "created_at INTEGER NOT NULL)" ); diff --git a/app/src/test/java/com/zcz/javatavern/data/TavernDatabaseTest.java b/app/src/test/java/com/zcz/javatavern/data/TavernDatabaseTest.java index 7015ed8..89e28dc 100644 --- a/app/src/test/java/com/zcz/javatavern/data/TavernDatabaseTest.java +++ b/app/src/test/java/com/zcz/javatavern/data/TavernDatabaseTest.java @@ -53,6 +53,11 @@ public void freshInstall_createsFullSchemaAndSeedsDefaults() { assertTrue(tableExists(database, TavernDatabase.TABLE_AGENT_AUDIT)); assertTrue(tableExists(database, TavernDatabase.TABLE_PRESETS)); assertTrue(tableExists(database, TavernDatabase.TABLE_GROUPS)); + assertTrue(tableExists(database, TavernDatabase.TABLE_PERSONAS)); + assertTrue(tableExists(database, TavernDatabase.TABLE_CHATS)); + assertTrue("fresh installs must have messages.chat_id from onCreate, " + + "not only via upgradeToVersion8", + columnExists(database, TavernDatabase.TABLE_MESSAGES, "chat_id")); try (Cursor cursor = database.rawQuery( "SELECT COUNT(*) FROM " + TavernDatabase.TABLE_CHARACTERS, null)) { @@ -217,6 +222,161 @@ public void upgradeFromVersion1_addsNewColumnsAndPreservesExistingData() { tableExists(upgraded, TavernDatabase.TABLE_MESSAGE_VERSIONS)); } + /** + * Regression test for a real bug: {@code upgradeToVersion8} (adds + * {@code messages.chat_id} and the {@code chats} table) was defined but + * never wired into {@code onUpgrade}, so real users upgrading from v7 + * would hit {@code SQLiteException: no such column: chat_id} the first + * time any multi-chat code ran a query against it. Builds the schema as + * it existed at v7 (after upgradeToVersion6/7, before upgradeToVersion8), + * seeds a pre-existing message with no chat_id, then reopens through + * {@link TavernDatabase} so the real {@code onUpgrade(db, 7, 8)} runs. + */ + @Test + public void upgradeFromVersion7_addsChatIdAndMigratesExistingMessagesToDefaultChat() { + File dbFile = context.getDatabasePath("tavern.db"); + try (SQLiteDatabase legacy = SQLiteDatabase.openOrCreateDatabase(dbFile, null)) { + legacy.execSQL("CREATE TABLE characters (" + + "id TEXT PRIMARY KEY," + + "name TEXT NOT NULL," + + "description TEXT NOT NULL," + + "personality TEXT NOT NULL DEFAULT ''," + + "scenario TEXT NOT NULL DEFAULT ''," + + "greeting TEXT NOT NULL," + + "system_prompt TEXT NOT NULL," + + "post_history_instructions TEXT NOT NULL DEFAULT ''," + + "creator_notes TEXT NOT NULL DEFAULT ''," + + "character_version TEXT NOT NULL DEFAULT ''," + + "mes_example TEXT NOT NULL DEFAULT ''," + + "alternate_greetings_json TEXT NOT NULL DEFAULT '[]'," + + "accent_color INTEGER NOT NULL," + + "source_hash TEXT NOT NULL UNIQUE," + + "avatar TEXT NOT NULL DEFAULT ''," + + "created_at INTEGER NOT NULL)"); + legacy.execSQL("CREATE TABLE world_entries (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT," + + "character_id TEXT NOT NULL," + + "keywords_json TEXT NOT NULL," + + "secondary_keys_json TEXT NOT NULL DEFAULT '[]'," + + "content TEXT NOT NULL," + + "enabled INTEGER NOT NULL," + + "constant_entry INTEGER NOT NULL," + + "position INTEGER NOT NULL," + + "sort_order INTEGER NOT NULL DEFAULT 100," + + "priority INTEGER NOT NULL DEFAULT 0," + + "depth INTEGER NOT NULL DEFAULT 4," + + "scan_depth INTEGER NOT NULL DEFAULT 100," + + "case_sensitive INTEGER NOT NULL DEFAULT 0," + + "match_whole_words INTEGER NOT NULL DEFAULT 0," + + "use_group_scoring INTEGER NOT NULL DEFAULT 0," + + "automation_id TEXT NOT NULL DEFAULT ''," + + "role TEXT NOT NULL DEFAULT 'system'," + + "vectorized INTEGER NOT NULL DEFAULT 0," + + "sticky INTEGER NOT NULL DEFAULT 0," + + "cooldown INTEGER NOT NULL DEFAULT 0," + + "probability INTEGER NOT NULL DEFAULT 100," + + "exclude_recursion INTEGER NOT NULL DEFAULT 0," + + "prevent_recursion INTEGER NOT NULL DEFAULT 0)"); + legacy.execSQL("CREATE TABLE messages (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT," + + "character_id TEXT NOT NULL," + + "role TEXT NOT NULL," + + "kind TEXT NOT NULL DEFAULT 'TEXT'," + + "title TEXT NOT NULL DEFAULT ''," + + "action_token TEXT NOT NULL DEFAULT ''," + + "action_type TEXT NOT NULL DEFAULT ''," + + "action_state TEXT NOT NULL DEFAULT 'NONE'," + + "attachment_path TEXT NOT NULL DEFAULT ''," + + "attachment_mime_type TEXT NOT NULL DEFAULT ''," + + "reply_to_message_id INTEGER NOT NULL DEFAULT -1," + + "reply_preview TEXT NOT NULL DEFAULT ''," + + "reaction TEXT NOT NULL DEFAULT ''," + + "speaker_id TEXT NOT NULL DEFAULT ''," + + "speaker_name TEXT NOT NULL DEFAULT ''," + + "version_count INTEGER NOT NULL DEFAULT 1," + + "active_version INTEGER NOT NULL DEFAULT 1," + + "content TEXT NOT NULL," + + "created_at INTEGER NOT NULL)"); + legacy.execSQL("CREATE TABLE agent_audit (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT," + + "character_id TEXT NOT NULL," + + "action_token TEXT NOT NULL," + + "action_type TEXT NOT NULL," + + "state TEXT NOT NULL," + + "detail TEXT NOT NULL," + + "created_at INTEGER NOT NULL)"); + legacy.execSQL("CREATE TABLE presets (" + + "id TEXT PRIMARY KEY, name TEXT NOT NULL, temperature TEXT, top_p TEXT," + + "max_tokens TEXT, frequency_penalty TEXT, presence_penalty TEXT," + + "created_at INTEGER NOT NULL)"); + legacy.execSQL("CREATE TABLE groups (" + + "id TEXT PRIMARY KEY, name TEXT NOT NULL, member_ids_json TEXT NOT NULL," + + "created_at INTEGER NOT NULL)"); + legacy.execSQL("CREATE TABLE message_versions (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, message_id INTEGER NOT NULL," + + "content TEXT NOT NULL, created_at INTEGER NOT NULL)"); + legacy.execSQL("CREATE TABLE personas (" + + "id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT ''," + + "is_default INTEGER NOT NULL DEFAULT 0, avatar TEXT NOT NULL DEFAULT ''," + + "created_at INTEGER NOT NULL)"); + + ContentValues character = new ContentValues(); + character.put("id", "pre-v8-character"); + character.put("name", "Old Data"); + character.put("description", "must survive the v8 upgrade"); + character.put("greeting", "hi"); + character.put("system_prompt", "be nice"); + character.put("accent_color", 0); + character.put("source_hash", "hash-v7"); + character.put("avatar", ""); + character.put("created_at", 1000L); + legacy.insertOrThrow("characters", null, character); + + ContentValues message = new ContentValues(); + message.put("character_id", "pre-v8-character"); + message.put("role", "USER"); + message.put("content", "message from before multi-chat existed"); + message.put("created_at", 2000L); + legacy.insertOrThrow("messages", null, message); + + legacy.execSQL("PRAGMA user_version = 7"); + } + + // Reopening through TavernDatabase triggers onUpgrade(db, 7, 8). + SQLiteDatabase upgraded = TavernDatabase.get(context).getWritableDatabase(); + + assertTrue("v8 chats table must exist after upgrade", + tableExists(upgraded, TavernDatabase.TABLE_CHATS)); + assertTrue("v8 messages.chat_id column must exist after upgrade", + columnExists(upgraded, TavernDatabase.TABLE_MESSAGES, "chat_id")); + + String expectedChatId = "default-pre-v8-character"; + try (Cursor cursor = upgraded.query( + TavernDatabase.TABLE_CHATS, + new String[]{"character_id", "name"}, + "id = ?", + new String[]{expectedChatId}, + null, null, null)) { + assertTrue("a default chat must be synthesized for the pre-existing character", + cursor.moveToFirst()); + assertEquals("pre-v8-character", cursor.getString(0)); + assertEquals("默认聊天", cursor.getString(1)); + } + + try (Cursor cursor = upgraded.query( + TavernDatabase.TABLE_MESSAGES, + new String[]{"content", "chat_id"}, + "character_id = ?", + new String[]{"pre-v8-character"}, + null, null, null)) { + assertTrue("pre-existing message row must survive the upgrade", + cursor.moveToFirst()); + assertEquals("message from before multi-chat existed", cursor.getString(0)); + assertEquals("pre-existing message must be bucketed into the default chat", + expectedChatId, cursor.getString(1)); + } + } + @Test public void legacyCharacterMigration_movesRowsAndDeletesSourceFile() { File legacyFile = context.getDatabasePath("characters.db"); @@ -308,4 +468,15 @@ private boolean tableExists(SQLiteDatabase database, String tableName) { return cursor.moveToFirst(); } } + + private boolean columnExists(SQLiteDatabase database, String table, String column) { + try (Cursor cursor = database.rawQuery("PRAGMA table_info(" + table + ")", null)) { + while (cursor.moveToNext()) { + if (column.equals(cursor.getString(1))) { + return true; + } + } + } + return false; + } }