Skip to content
32 changes: 29 additions & 3 deletions app/src/main/java/com/zcz/javatavern/ChatViewModel.java
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public static final class StreamSnapshot {

/** Application-context repository — lives for the whole ViewModel lifetime. */
private final ChatRepository ownedRepository;
private final OpenAiCompatibleClient modelClient = new OpenAiCompatibleClient();
private final OpenAiCompatibleClient modelClient;

/** One active session per request; null means idle. */
private final AtomicReference<StreamSession> currentSession = new AtomicReference<>(null);
Expand All @@ -84,6 +84,15 @@ public static final class StreamSnapshot {
*/
private volatile long activeRegenerateMessageId = -1L;

/**
* 当前请求累积的推理内容(DeepSeek R1 / Claude extended thinking)。独立于
* {@link StreamSession}/{@link StreamAccumulator} 之外的一条并行侧信道——
* 那两个类是这个项目里状态机不变量最严格的部分(RT-1~RT-5 全套测试覆盖
* 的"只终态一次/只持久化一次"),不值得为了推理内容再改一遍;这里只在
* onTerminalText 持久化时读一次快照,不影响主文本的状态转换。
*/
private final StringBuilder reasoningBuffer = new StringBuilder();

private final MutableLiveData<StreamSnapshot> streamState = new MutableLiveData<>(null);

/**
Expand All @@ -105,6 +114,7 @@ public static final class StreamSnapshot {
public ChatViewModel(@NonNull Application application) {
super(application);
ownedRepository = new ChatRepository(application.getApplicationContext());
modelClient = new OpenAiCompatibleClient(application.getApplicationContext());
}

public LiveData<StreamSnapshot> getStreamState() {
Expand Down Expand Up @@ -212,6 +222,9 @@ private boolean startStream(
final String charId = sessionCharacterId;
activeGroupSpeaker = groupSpeaker;
activeRegenerateMessageId = regenerateMessageId;
synchronized (reasoningBuffer) {
reasoningBuffer.setLength(0);
}

final String emptyFallback = getString(R.string.stream_empty_fallback);
final String stoppedMarker = getString(R.string.stream_stopped_marker);
Expand Down Expand Up @@ -244,6 +257,14 @@ public void onDelta(String delta) {
scheduleFlush(session);
}

@Override
public void onReasoningDelta(String delta) {
if (currentSession.get() != session) return;
synchronized (reasoningBuffer) {
reasoningBuffer.append(delta);
}
}

@Override
public void onComplete() {
mainHandler.post(() -> {
Expand Down Expand Up @@ -309,19 +330,24 @@ public void onTerminalText(long opId, String text, long createdAt,
final String cid = session.getCharacterId();
final CharacterProfile speaker = activeGroupSpeaker;
final long targetId = activeRegenerateMessageId;
final String reasoningText;
synchronized (reasoningBuffer) {
reasoningText = reasoningBuffer.toString();
}
AppExecutors.get().diskIo().execute(() -> {
long rowId;
if (speaker != null) {
rowId = ownedRepository.addGroupMessage(
cid, ChatMessage.Role.ASSISTANT, text, createdAt,
speaker.getId(), speaker.getName());
speaker.getId(), speaker.getName(), reasoningText);
} else if (targetId > 0) {
// 重 roll:内容作为新版本写回原消息,位置不变、旧版本保留。
// 思考过程不随版本单独存一份,只保留首次生成时的那份。
ownedRepository.appendMessageVersion(targetId, text, createdAt);
rowId = targetId;
} else {
rowId = ownedRepository.addMessage(
cid, ChatMessage.Role.ASSISTANT, text, createdAt);
cid, ChatMessage.Role.ASSISTANT, text, createdAt, reasoningText);
}
mainHandler.post(() -> {
StreamSnapshot cur = streamState.getValue();
Expand Down
71 changes: 67 additions & 4 deletions app/src/main/java/com/zcz/javatavern/data/ChatHistoryStore.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,15 @@ public final class ChatHistoryStore {
"action_token", "action_type", "action_state",
"attachment_path", "attachment_mime_type",
"reply_to_message_id", "reply_preview", "reaction", "speaker_name",
"version_count", "active_version"
"version_count", "active_version", "reasoning_content"
};
/** 与 {@link #MESSAGE_COLUMNS} 等价的带表别名版本,供 JOIN 查询复用。 */
private static final String MESSAGE_COLUMNS_QUALIFIED =
"m.id, m.role, m.kind, m.title, m.content, m.created_at, " +
"m.action_token, m.action_type, m.action_state, " +
"m.attachment_path, m.attachment_mime_type, " +
"m.reply_to_message_id, m.reply_preview, m.reaction, m.speaker_name, " +
"m.version_count, m.active_version";
"m.version_count, m.active_version, m.reasoning_content";

private final TavernDatabase database;

Expand Down Expand Up @@ -175,6 +175,17 @@ public List<ChatMessage> loadMessageContext(String characterId, long targetId, i
}

public long addMessage(String characterId, ChatMessage.Role role, String content, long createdAt) {
return addMessage(characterId, role, content, createdAt, "");
}

/** 同上,带推理内容(新生成的 assistant 回复用;重 roll 不走这里)。 */
public long addMessage(
String characterId,
ChatMessage.Role role,
String content,
long createdAt,
String reasoningContent
) {
return addMessage(
characterId,
role,
Expand All @@ -184,7 +195,13 @@ public long addMessage(String characterId, ChatMessage.Role role, String content
createdAt,
"",
"",
ChatMessage.ActionState.NONE
ChatMessage.ActionState.NONE,
"",
"",
-1,
"",
"",
reasoningContent
);
}

Expand Down Expand Up @@ -281,6 +298,36 @@ public long addMessage(
long replyToMessageId,
String replyPreview,
String reaction
) {
return addMessage(
characterId, role, kind, title, content, createdAt,
actionToken, actionType, actionState,
attachmentPath, attachmentMimeType,
replyToMessageId, replyPreview, reaction, ""
);
}

/**
* 带推理内容(DeepSeek R1 / Claude extended thinking)的入口——只有新生成的
* assistant 回复会传非空 reasoningContent,重 roll({@link #appendMessageVersion})
* 不走这里,不单独为每个版本存一份思考过程。
*/
public long addMessage(
String characterId,
ChatMessage.Role role,
ChatMessage.Kind kind,
String title,
String content,
long createdAt,
String actionToken,
String actionType,
ChatMessage.ActionState actionState,
String attachmentPath,
String attachmentMimeType,
long replyToMessageId,
String replyPreview,
String reaction,
String reasoningContent
) {
ContentValues values = new ContentValues();
values.put("character_id", characterId);
Expand All @@ -297,6 +344,7 @@ public long addMessage(
values.put("reaction", reaction);
values.put("content", content);
values.put("created_at", createdAt);
values.put("reasoning_content", reasoningContent == null ? "" : reasoningContent);
return database.getWritableDatabase().insertOrThrow(TABLE_MESSAGES, null, values);
}

Expand All @@ -308,6 +356,19 @@ public long addGroupMessage(
long createdAt,
String speakerId,
String speakerName
) {
return addGroupMessage(characterId, role, content, createdAt, speakerId, speakerName, "");
}

/** 同上,带推理内容——语义和 {@link #addMessage(String, ChatMessage.Role, ChatMessage.Kind, String, String, long, String, String, ChatMessage.ActionState, String, String, long, String, String, String)} 一致。 */
public long addGroupMessage(
String characterId,
ChatMessage.Role role,
String content,
long createdAt,
String speakerId,
String speakerName,
String reasoningContent
) {
ContentValues values = new ContentValues();
values.put("character_id", characterId);
Expand All @@ -318,6 +379,7 @@ public long addGroupMessage(
values.put("created_at", createdAt);
values.put("speaker_id", speakerId);
values.put("speaker_name", speakerName);
values.put("reasoning_content", reasoningContent == null ? "" : reasoningContent);
return database.getWritableDatabase().insertOrThrow(TABLE_MESSAGES, null, values);
}

Expand Down Expand Up @@ -707,7 +769,8 @@ private ChatMessage readMessage(Cursor cursor) {
cursor.getString(13),
cursor.getString(14),
cursor.getInt(15),
cursor.getInt(16)
cursor.getInt(16),
cursor.getString(17)
);
}

Expand Down
39 changes: 37 additions & 2 deletions app/src/main/java/com/zcz/javatavern/data/ChatRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.zcz.javatavern.model.CharacterProfile;
import com.zcz.javatavern.model.ChatMessage;
import com.zcz.javatavern.model.HomeFeedItem;
import com.zcz.javatavern.util.GreetingSelector;

import java.util.ArrayList;
import java.util.List;
Expand Down Expand Up @@ -43,6 +44,7 @@ public boolean hasMoreHistory() {
private final CharacterRepository characterRepository;
private final ChatHistoryStore historyStore;
private final LongTermMemoryStore memoryStore;
private final GreetingSelector greetingSelector = new GreetingSelector();

public ChatRepository(Context context) {
Context applicationContext = context.getApplicationContext();
Expand Down Expand Up @@ -87,16 +89,24 @@ public SessionData loadSession(String requestedCharacterId, int pageSize) {
boolean hasMoreHistory = messages.size() >= pageSize;
if (messages.isEmpty()) {
long createdAt = System.currentTimeMillis();
// 有备用开场白时随机挑一条(SillyTavern 风格:每次开新聊天换一个开场),
// 没有备用开场白的角色(绝大多数)行为和之前完全一样,总是用主开场白。
List<String> alternateGreetings = character.getAlternateGreetings();
String greeting = greetingSelector.selectGreeting(
character.getGreeting(),
alternateGreetings,
alternateGreetings.isEmpty() ? -1 : -2
);
long id = historyStore.addMessage(
character.getId(),
ChatMessage.Role.ASSISTANT,
character.getGreeting(),
greeting,
createdAt
);
messages.add(new ChatMessage(
id,
ChatMessage.Role.ASSISTANT,
character.getGreeting(),
greeting,
createdAt
));
}
Expand Down Expand Up @@ -125,6 +135,20 @@ public long addGroupMessage(
groupId, role, content, createdAt, speakerId, speakerName);
}

/** 同上,带推理内容(DeepSeek R1 / Claude extended thinking 的思考过程)。 */
public long addGroupMessage(
String groupId,
ChatMessage.Role role,
String content,
long createdAt,
String speakerId,
String speakerName,
String reasoningContent
) {
return historyStore.addGroupMessage(
groupId, role, content, createdAt, speakerId, speakerName, reasoningContent);
}

public List<ChatMessage> searchMessages(String characterId, String query, int limit) {
return historyStore.searchMessages(characterId, query, limit);
}
Expand All @@ -141,6 +165,17 @@ public long addMessage(String characterId, ChatMessage.Role role, String content
return historyStore.addMessage(characterId, role, content, createdAt);
}

/** 同上,带推理内容(DeepSeek R1 / Claude extended thinking 的思考过程)。 */
public long addMessage(
String characterId,
ChatMessage.Role role,
String content,
long createdAt,
String reasoningContent
) {
return historyStore.addMessage(characterId, role, content, createdAt, reasoningContent);
}

public long addMessage(
String characterId,
ChatMessage.Role role,
Expand Down
21 changes: 21 additions & 0 deletions app/src/main/java/com/zcz/javatavern/data/ProviderCatalog.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@

public final class ProviderCatalog {
public static final String CUSTOM_ID = "custom";
/**
* 非 OpenAI 兼容格式的原生 provider id——{@code OpenAiCompatibleClient}
* 靠这两个 id 分流到 {@code AnthropicProvider}/{@code GoogleGeminiProvider},
* 而不是走默认的 {@code /chat/completions} 请求格式。
*/
public static final String ANTHROPIC_ID = "anthropic";
public static final String GOOGLE_ID = "google";
private static final List<ProviderPreset> PRESETS = List.of(
new ProviderPreset(
"openai",
Expand All @@ -26,6 +33,20 @@ public final class ProviderCatalog {
"",
"从 OpenRouter 模型页复制模型 ID"
),
new ProviderPreset(
ANTHROPIC_ID,
"Anthropic",
"https://api.anthropic.com/v1",
"",
"例如 claude-sonnet-4-5"
),
new ProviderPreset(
GOOGLE_ID,
"Google Gemini",
"https://generativelanguage.googleapis.com/v1beta",
"",
"例如 gemini-2.5-flash"
),
new ProviderPreset(
CUSTOM_ID,
"其他兼容服务",
Expand Down
15 changes: 14 additions & 1 deletion app/src/main/java/com/zcz/javatavern/data/TavernDatabase.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 8;
private static final int DATABASE_VERSION = 9;

public static final String TABLE_CHARACTERS = "characters";
public static final String TABLE_WORLD_ENTRIES = "world_entries";
Expand Down Expand Up @@ -124,6 +124,9 @@ public void onUpgrade(SQLiteDatabase database, int oldVersion, int newVersion) {
if (oldVersion < 8) {
upgradeToVersion8(database);
}
if (oldVersion < 9) {
upgradeToVersion9(database);
}
}

/**
Expand Down Expand Up @@ -210,6 +213,15 @@ private void upgradeToVersion8(SQLiteDatabase database) {
);
}

/**
* 推理内容支持:消息表增加 reasoning_content 字段
* 用于存储 DeepSeek R1 等模型的思考过程
*/
private void upgradeToVersion9(SQLiteDatabase database) {
database.execSQL("ALTER TABLE " + TABLE_MESSAGES +
" ADD COLUMN reasoning_content TEXT NOT NULL DEFAULT ''");
}

private void upgradeToVersion2(SQLiteDatabase database) {
// 世界书高级字段:position 列已存在但旧版本存的是列表索引(语义错误),
// 本次补充其余字段并把 position 重置为 after_char,保持既有"拼在角色卡后"行为。
Expand Down Expand Up @@ -303,6 +315,7 @@ private void createMessageTables(SQLiteDatabase database) {
"version_count INTEGER NOT NULL DEFAULT 1," +
"active_version INTEGER NOT NULL DEFAULT 1," +
"chat_id TEXT NOT NULL DEFAULT 'default'," +
"reasoning_content TEXT NOT NULL DEFAULT ''," +
"content TEXT NOT NULL," +
"created_at INTEGER NOT NULL)"
);
Expand Down
Loading
Loading