Improve generating message abort behavior

This commit is contained in:
Jody Doolittle 2025-07-02 08:28:10 -07:00
parent aa7dc8ad92
commit 07eb651a18
17 changed files with 571 additions and 722 deletions

View file

@ -8,6 +8,7 @@ set NODE_ARCHIVE=node-archive.win.zip
set NODE_DIR=node-v20.13.1-win-x64
set NODE_BIN_PATH=%NODE_DIR%\node.exe
REM Download Node.js if needed (do this first while we might still have admin privileges)
if not exist "%NODE_BIN%" (
echo Downloading Node.js...
powershell -Command "Invoke-WebRequest -Uri %NODE_URL% -OutFile %NODE_ARCHIVE%"
@ -16,4 +17,13 @@ if not exist "%NODE_BIN%" (
rmdir /s /q %NODE_DIR%
del %NODE_ARCHIVE%
)
REM Check if running with administrator privileges
net session >nul 2>&1
if %errorLevel% == 0 (
echo Administrator privileges detected. Re-launching with reduced privileges for PostgreSQL compatibility...
powershell -Command "Start-Process -FilePath '%NODE_BIN%' -ArgumentList '%DIR%build\index.js %*' -Verb runAsUser"
exit /b
)
"%NODE_BIN%" "%DIR%build\index.js" %*

0
docker-compose.yml Normal file
View file

0
installer-package.json Normal file
View file

View file

@ -43,11 +43,11 @@
}
.rendered-chat-message-content em {
@apply text-surface-700-300;
@apply text-surface-800-200;
}
.rendered-chat-message-content span.quoted-text {
@apply text-tertiary-900-100;
@apply text-tertiary-950-50;
}
.sidebar-list-item {

View file

@ -195,7 +195,7 @@
class="bg-surface-100-900 relative h-full max-h-[100dvh] w-full justify-between"
>
<div
class="relative flex min-h-svh max-h-svh min-w-full max-w-full flex-1 flex-col overflow-y-auto lg:flex-row lg:gap-2"
class="relative flex h-svh min-w-full max-w-full flex-1 flex-col lg:flex-row lg:gap-2 overflow-hidden"
>
<!-- Left Sidebar -->
<aside class="desktop-sidebar">
@ -248,9 +248,9 @@
{/if}
</aside>
<!-- Main Content -->
<main class="flex flex-col min-h-svh max-h-svh overflow-hidden">
<main class="flex flex-col h-full overflow-hidden">
<Header />
<div class="h-full overflow-hidden">
<div class="flex-1 overflow-auto">
{@render children?.()}
</div>
</main>

View file

@ -80,10 +80,11 @@ export abstract class BaseConnectionAdapter {
}
abstract generate(): Promise<
[
string | ((cb: (chunk: string) => void) => Promise<void>),
CompiledPrompt
]
{
completionResult: string | ((cb: (chunk: string) => void) => Promise<void>),
compiledPrompt: CompiledPrompt,
isAborted: boolean
}
>
abort() {

View file

@ -112,13 +112,14 @@ class LMStudioAdapter extends BaseConnectionAdapter {
}
async generate(): Promise<
[
string | ((cb: (chunk: string) => void) => Promise<void>),
CompiledPrompt
]
{
completionResult: string | ((cb: (chunk: string) => void) => Promise<void>),
compiledPrompt: CompiledPrompt,
isAborted: boolean
}
> {
const modelName =
this.connection.model ?? LMStudioAdapter.connectionDefaults.baseUrl
this.connection.model ?? connectionDefaults.baseUrl
const stream = this.connection!.extraJson?.stream || false
if (typeof modelName !== "string")
throw new Error("LMStudioAdapter: model must be a string")
@ -147,15 +148,10 @@ class LMStudioAdapter extends BaseConnectionAdapter {
const compiledPrompt: CompiledPrompt =
await this.compilePrompt({})
// Select prompt or messages for LM Studio
let prompt: string = ""
// if ("prompt" in compiledPrompt && typeof compiledPrompt.prompt === "string") {
prompt = compiledPrompt.prompt
// } else if ("messages" in compiledPrompt && Array.isArray(compiledPrompt.messages)) {
// prompt = compiledPrompt.messages.map(m => m.content).join("\n")
// } else {
// throw new Error("Compiled prompt missing both prompt and messages")
// }
prompt = compiledPrompt.prompt!
let options: LLMPredictionOpts<unknown> = {
stopStrings: stop,
maxTokens: this.sampling.responseTokensEnabled
@ -170,8 +166,8 @@ class LMStudioAdapter extends BaseConnectionAdapter {
const modelClient = await this.getModelClient(modelName)
if (stream) {
return [
async (cb: (chunk: string) => void) => {
return {
completionResult: async (cb: (chunk: string) => void) => {
let fullContent = ""
let lastChunk = ""
let abortedEarly = false
@ -198,8 +194,9 @@ class LMStudioAdapter extends BaseConnectionAdapter {
cb("FAILURE: " + (e.message || String(e)))
}
},
compiledPrompt
]
compiledPrompt,
isAborted: this.isAborting
}
} else {
const content = await (async () => {
try {
@ -222,7 +219,7 @@ class LMStudioAdapter extends BaseConnectionAdapter {
return "FAILURE: " + (e.message || String(e))
}
})()
return [content ?? "", compiledPrompt]
return {completionResult:content ?? "", compiledPrompt, isAborted: this.isAborting}
}
}

View file

@ -280,10 +280,11 @@ class LlamaCppAdapter extends BaseConnectionAdapter {
}
async generate(): Promise<
[
string | ((cb: (chunk: string) => void) => Promise<void>),
CompiledPrompt
]
{
completionResult: string | ((cb: (chunk: string) => void) => Promise<void>),
compiledPrompt: CompiledPrompt,
isAborted: boolean
}
> {
const stream = this.connection.extraJson?.stream || false
// Prepare stop strings
@ -337,8 +338,8 @@ class LlamaCppAdapter extends BaseConnectionAdapter {
"http://localhost:8080"
if (stream) {
return [
async (cb: (chunk: string) => void) => {
return {
completionResult: async (cb: (chunk: string) => void) => {
let content = ""
let cancelTokenSource = axios.CancelToken.source();
try {
@ -380,13 +381,14 @@ class LlamaCppAdapter extends BaseConnectionAdapter {
cb("FAILURE: " + (e.message || String(e)))
}
},
compiledPrompt
]
compiledPrompt,
isAborted: this.isAborting
}
} else {
const abortController = new AbortController();
if (this.isAborting) {
abortController.abort();
return ["FAILURE: Request aborted by user.", compiledPrompt];
return {completionResult: "FAILURE: Request aborted by user.", compiledPrompt, isAborted: true};
}
try {
const response = await axios.post<CompletionResponse>(
@ -396,12 +398,12 @@ class LlamaCppAdapter extends BaseConnectionAdapter {
)
const result = response.data
const content = result?.content || result?.response || ""
return [content, compiledPrompt]
return {completionResult: content, compiledPrompt, isAborted: this.isAborting}
} catch (e: any) {
if (axios.isCancel?.(e) || e?.code === 'ERR_CANCELED' || e?.message?.includes('aborted')) {
return ["FAILURE: Request aborted by user.", compiledPrompt]
return {completionResult: "FAILURE: Request aborted by user.", compiledPrompt, isAborted: true}
}
return ["FAILURE: " + (e.message || String(e)), compiledPrompt]
return {completionResult: "FAILURE: " + (e.message || String(e)), compiledPrompt, isAborted: true}
}
}
}

View file

@ -93,10 +93,11 @@ class OllamaAdapter extends BaseConnectionAdapter {
}
async generate(): Promise<
[
string | ((cb: (chunk: string) => void) => Promise<void>),
CompiledPrompt
]
{
completionResult: string | ((cb: (chunk: string) => void) => Promise<void>),
compiledPrompt: CompiledPrompt,
isAborted: boolean
}
> {
const model = this.connection.model ?? connectionDefaults.baseUrl
const stream = this.connection!.extraJson?.stream || false
@ -163,8 +164,8 @@ class OllamaAdapter extends BaseConnectionAdapter {
}
if (stream) {
return [
async (cb: (chunk: string) => void) => {
return {
completionResult: async (cb: (chunk: string) => void) => {
let content = ""
let abortedEarly = false
try {
@ -219,8 +220,9 @@ class OllamaAdapter extends BaseConnectionAdapter {
cb("FAILURE: " + (e.message || String(e)))
}
},
compiledPrompt
]
compiledPrompt,
isAborted: this.isAborting
}
} else {
const content = await (async () => {
let content = ""
@ -271,7 +273,7 @@ class OllamaAdapter extends BaseConnectionAdapter {
return "FAILURE: " + (e.message || String(e))
}
})()
return [content ?? "", compiledPrompt]
return {completionResult: content ?? "", compiledPrompt, isAborted: this.isAborting}
}
}
// --- Abort in-flight Ollama request ---

View file

@ -58,10 +58,11 @@ export class OpenAIChatAdapter extends BaseConnectionAdapter {
}
async generate(): Promise<
[
string | ((cb: (chunk: string) => void) => Promise<void>),
CompiledPrompt
]
{
completionResult: string | ((cb: (chunk: string) => void) => Promise<void>),
compiledPrompt: CompiledPrompt,
isAborted: boolean
}
> {
const apiKey = this.connection.extraJson?.apiKey
const baseURL =
@ -108,8 +109,8 @@ export class OpenAIChatAdapter extends BaseConnectionAdapter {
try {
if (stream) {
return [
async (cb: (chunk: string) => void) => {
return {
completionResult:async (cb: (chunk: string) => void) => {
const streamResp =
await openaiClient.chat.completions.create({
...params,
@ -127,8 +128,9 @@ export class OpenAIChatAdapter extends BaseConnectionAdapter {
}
}
},
compiledPrompt
]
compiledPrompt,
isAborted: this.isAborting
}
} else {
const response =
await openaiClient.chat.completions.create(params)
@ -140,7 +142,7 @@ export class OpenAIChatAdapter extends BaseConnectionAdapter {
) {
content = response.choices[0].message.content || ""
}
return [content, compiledPrompt]
return {completionResult: content, compiledPrompt, isAborted: this.isAborting}
}
} catch (err) {
console.error(

View file

@ -4,18 +4,18 @@ import * as dbConfig from "./drizzle.config"
import type { MigrationConfig } from "drizzle-orm/migrator"
import fs from "fs"
import { dev } from "$app/environment"
import { drizzle } from 'drizzle-orm/node-postgres'
import { drizzle } from "drizzle-orm/node-postgres"
import { sync } from "./defaults"
import { startPg } from "./postgres.service"
const firstInit = await startPg()
export let db = drizzle(dbConfig.postgresUrl, {schema})
export let db = drizzle(dbConfig.postgresUrl, { schema })
// Compare two version strings in '0.0.0' format
export function compareVersions(a: string, b: string): -1 | 0 | 1 {
const pa = a.split('.').map(Number)
const pb = b.split('.').map(Number)
const pa = a.split(".").map(Number)
const pb = b.split(".").map(Number)
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const na = pa[i] || 0
const nb = pb[i] || 0
@ -51,17 +51,30 @@ async function runMigrations(oldVersion?: string) {
} as MigrationConfig)
console.log("Migrations applied.")
await sync()
////
// DEPRECATE: Temporary migration from SQLite to PostgreSQL - remove in 0.4.0
////
const { dbPath: sqliteDbPath } = await import("../db_old/drizzle.config")
const sqliteDbExists = fs.existsSync(sqliteDbPath)
if (sqliteDbExists) {
console.log("SQLite database found, migrating to PostgreSQL...")
const { migrateToPg } = await import("../db_old/migrateToPg")
await migrateToPg()
}
}
// Run migrations if in production environment
if (!dev || firstInit) {
// If it doesn't exist, create a meta.json file in the data directory
const metaPath = dbConfig.dataDir + "/meta.json"
// Check if the file exists
if (!fs.existsSync(metaPath)) {
// Create the file with default content
fs.writeFileSync(metaPath, JSON.stringify({ version: "0.0.0" }, null, 2))
fs.writeFileSync(
metaPath,
JSON.stringify({ version: "0.0.0" }, null, 2)
)
}
// Check meta.json for version
@ -69,7 +82,9 @@ if (!dev || firstInit) {
// @ts-ignore
const appVersion = __APP_VERSION__
if (!appVersion) {
throw new Error("App version is not defined. Please set __APP_VERSION__.")
throw new Error(
"App version is not defined. Please set __APP_VERSION__."
)
}
const versionCompare = compareVersions(meta.version, appVersion)
@ -85,18 +100,21 @@ if (!dev || firstInit) {
console.log(`Updated meta.json to version ${appVersion}.`)
break
case 1:
console.warn(`Warning: Database version (${meta.version}) is newer than app version (${appVersion}).`)
console.warn(
`Warning: Database version (${meta.version}) is newer than app version (${appVersion}).`
)
// This could happen if the app version is rolled back or if the database was manually updated
// Handle this case as needed, e.g., notify the user or log an error
throw new Error(
`Database version (${meta.version}) is newer than app version (${appVersion}). Please check your database integrity.`
)
default:
console.error("Unexpected version comparison result:", versionCompare)
console.error(
"Unexpected version comparison result:",
versionCompare
)
throw new Error("Unexpected version comparison result")
}
} else {
await sync()
// const { migrateToPg } = await import("../db_old/migrateToPg")
// await migrateToPg()
}

View file

@ -549,7 +549,3 @@ export const chatLorebooksRelations = relations(chatLorebooks, ({ one }) => ({
references: [lorebooks.id]
})
}))
// For JSON/text columns, use text(...).notNull() if required, but do not use { mode: "json" } or .default({})
// Example fix for a JSON/text column:
// myJsonColumn: text("my_json_column").notNull(),

View file

@ -63,109 +63,90 @@ export async function migrateToPg() {
// Get characters from SQLite
const characters = await sqlite.query.characters.findMany()
characters.forEach(async (character) => {
// Map null fields to undefined for compatibility with schema
const mappedCharacter = {
// Convert any String[] fields to string[] (primitive)
const safeCharacter = {
...character,
updatedAt: undefined,
createdAt: undefined,
metadata: character.metadata || {},
lorebookId: null,
// Add other nullable fields as needed
alternateGreetings: character.alternateGreetings
? character.alternateGreetings.map((s: any) => s.toString())
: null,
exampleDialogues: character.exampleDialogues
? character.exampleDialogues.map((s: any) => s.toString())
: null,
}
return await tx.insert(schema.characters).values(mappedCharacter)
return await tx.insert(schema.characters).values({
name: safeCharacter.name,
description: safeCharacter.description ?? "",
userId: safeCharacter.userId,
nickname: safeCharacter.nickname ?? null,
characterVersion: safeCharacter.characterVersion ?? null,
personality: safeCharacter.personality ?? null,
scenario: safeCharacter.scenario ?? null,
firstMessage: safeCharacter.firstMessage ?? null,
alternateGreetings: safeCharacter.alternateGreetings ?? null,
exampleDialogues: safeCharacter.exampleDialogues ?? null,
avatar: safeCharacter.avatar ?? null,
creatorNotes: safeCharacter.creatorNotes ?? null,
creatorNotesMultilingual: safeCharacter.creatorNotesMultilingual ?? null,
groupOnlyGreetings: safeCharacter.groupOnlyGreetings ?? null,
postHistoryInstructions: safeCharacter.postHistoryInstructions ?? null,
isFavorite: !!safeCharacter.isFavorite,
})
})
// Get personas from SQLite
const personas = await sqlite.query.personas.findMany()
personas.forEach(async (persona) => {
return await tx.insert(schema.personas).values({
...persona,
updatedAt: undefined,
createdAt: undefined,
})
})
// Get lorebooks from SQLite
const lorebooks = await sqlite.query.lorebooks.findMany()
lorebooks.forEach(async (lorebook) => {
return await tx.insert(schema.lorebooks).values({
...lorebook,
updatedAt: undefined,
createdAt: undefined,
})
})
// Get lorebook bindings from SQLite
const lorebookBindings = await sqlite.query.lorebookBindings.findMany()
lorebookBindings.forEach(async (binding) => {
return await tx.insert(schema.lorebookBindings).values({
...binding,
})
})
// Get worldLoreEntries from SQLite
const worldLoreEntries = await sqlite.query.worldLoreEntries.findMany()
worldLoreEntries.forEach(async (entry) => {
return await tx.insert(schema.worldLoreEntries).values({
...entry,
updatedAt: undefined,
createdAt: undefined,
})
})
// Get characterLoreEntries from SQLite
const characterLoreEntries = await sqlite.query.characterLoreEntries.findMany()
characterLoreEntries.forEach(async (entry) => {
return await tx.insert(schema.characterLoreEntries).values({
...entry,
updatedAt: undefined,
createdAt: undefined,
})
})
// Get historyEntries from SQLite
const historyEntries = await sqlite.query.historyEntries.findMany()
historyEntries.forEach(async (entry) => {
return await tx.insert(schema.historyEntries).values({
...entry,
date: undefined,
updatedAt: undefined,
createdAt: undefined,
day: entry.date?.day || undefined,
month: entry.date?.month || undefined,
year: entry.date?.year || undefined,
})
return await tx.insert(schema.personas).values({
name: persona.name || "",
description: persona.description || "",
userId: persona.userId,
avatar: persona.avatar || null,
isDefault: !!persona.isDefault,
position: persona.position || null
})
})
// Get chats from SQLite
const chats = await sqlite.query.chats.findMany()
chats.forEach(async (chat) => {
return await tx.insert(schema.chats).values({
...chat,
updatedAt: undefined,
createdAt: undefined,
})
return await tx.insert(schema.chats).values({
id: chat.id,
name: chat.name || "", // Add the required name property
userId: chat.userId,
scenario: chat.scenario,
isGroup: !!chat.isGroup,
groupReplyStrategy: chat.group_reply_strategy,
})
})
// Get chatCharacters from SQLite
const chatCharacters = await sqlite.query.chatCharacters.findMany()
chatCharacters.forEach(async (chatCharacter) => {
return await tx.insert(schema.chatCharacters).values({
...chatCharacter,
updatedAt: undefined,
createdAt: undefined,
})
// Map null isActive to undefined for compatibility with schema
const mappedChatCharacter = {
...chatCharacter,
isActive: chatCharacter.isActive === null ? undefined : chatCharacter.isActive,
}
return await tx.insert(schema.chatCharacters).values(mappedChatCharacter)
})
// Get chatPersonas from SQLite
const chatPersonas = await sqlite.query.chatPersonas.findMany()
chatPersonas.forEach(async (chatPersona) => {
return await tx.insert(schema.chatPersonas).values({
...chatPersona,
updatedAt: undefined,
createdAt: undefined,
})
return await tx.insert(schema.chatPersonas).values(chatPersona)
})
// Get chatMessages from SQLite
const chatMessages = await sqlite.query.chatMessages.findMany()
chatMessages.forEach(async (chatMessage) => {
return await tx.insert(schema.chatMessages).values({
...chatMessage,
updatedAt: undefined,
createdAt: undefined,
metadata: chatMessage.metadata || {},
})
return await tx.insert(schema.chatMessages).values({
id: chatMessage.id,
userId: chatMessage.userId,
content: chatMessage.content, // Add the required content property
chatId: chatMessage.chatId,
characterId: chatMessage.characterId ?? null,
personaId: chatMessage.personaId ?? null,
adapterId: chatMessage.adapterId ?? null,
role: chatMessage.role ?? "user",
isEdited: !!chatMessage.isEdited,
isGenerating: !!chatMessage.isGenerating,
isHidden: !!chatMessage.isHidden,
})
})
// Get users from SQLite
const users = await sqlite.query.users.findMany()

View file

@ -1,609 +1,411 @@
import { updated } from "$app/state"
import { relations, sql } from "drizzle-orm"
import {
sqliteTable,
integer,
text,
numeric,
real,
blob,
SQLiteBoolean,
uniqueIndex
sqliteTable,
integer,
text,
numeric,
real,
blob,
SQLiteBoolean
} from "drizzle-orm/sqlite-core"
import { TokenCounterManager } from "../utils/TokenCounterManager"
import { group } from "console"
import { GroupReplyStrategies } from "../../shared/constants/GroupReplyStrategies"
import { lorebook } from "../sockets/lorebooks"
export const users = sqliteTable("users", {
id: integer("id").primaryKey(),
username: text("username").notNull(),
activeConnectionId: integer("active_connection_id").references(
() => connections.id,
{
onDelete: "set null"
}
),
activeSamplingConfigId: integer("active_sampling_id").references(
() => samplingConfigs.id,
{
onDelete: "set null"
}
),
activeContextConfigId: integer("active_context_config_id").references(
() => contextConfigs.id,
{
onDelete: "set null"
}
),
activePromptConfigId: integer("active_prompt_config_id").references(
() => promptConfigs.id,
{
onDelete: "set null"
}
)
id: integer("id").primaryKey(),
username: text("username").notNull(),
activeConnectionId: integer("active_connection_id").references(() => connections.id, {
onDelete: "set null"
}),
activeSamplingConfigId: integer("active_sampling_id").references(() => samplingConfigs.id, {
onDelete: "set null"
}),
activeContextConfigId: integer("active_context_config_id").references(() => contextConfigs.id, {
onDelete: "set null"
}),
activePromptConfigId: integer("active_prompt_config_id").references(() => promptConfigs.id, {
onDelete: "set null"
})
})
export const userRelations = relations(users, ({ many, one }) => ({
lorebooks: many(lorebooks),
characters: many(characters),
activeSamplingConfig: one(samplingConfigs, {
fields: [users.activeSamplingConfigId],
references: [samplingConfigs.id]
}),
activeConnection: one(connections, {
fields: [users.activeConnectionId],
references: [connections.id]
}),
activeContextConfig: one(contextConfigs, {
fields: [users.activeContextConfigId],
references: [contextConfigs.id]
}),
activePromptConfig: one(promptConfigs, {
fields: [users.activePromptConfigId],
references: [promptConfigs.id]
}),
personas: many(personas)
lorebooks: many(lorebooks),
characters: many(characters),
activeSamplingConfig: one(samplingConfigs, {
fields: [users.activeSamplingConfigId],
references: [samplingConfigs.id]
}),
activeConnection: one(connections, {
fields: [users.activeConnectionId],
references: [connections.id]
}),
activeContextConfig: one(contextConfigs, {
fields: [users.activeContextConfigId],
references: [contextConfigs.id]
}),
activePromptConfig: one(promptConfigs, {
fields: [users.activePromptConfigId],
references: [promptConfigs.id]
}),
personas: many(personas)
}))
export const samplingConfigs = sqliteTable("sampling_configs", {
id: integer("id").primaryKey(),
name: text("name").notNull(), // Name for this sampling config (for selection)
isImmutable: integer("is_immutable", { mode: "boolean" }).default(false), // Is this the built-in config? Then we don't want to allow mutation/deletion
id: integer("id").primaryKey(),
name: text("name").notNull(), // Name for this sampling config (for selection)
isImmutable: integer("is_immutable", { mode: "boolean" }).default(0), // Is this the built-in config? Then we don't want to allow mutation/deletion
// Tuned defaults for roleplay:
// More creative and less repetitive
temperature: real("temperature").default(0.7), // Higher = more creative
temperatureEnabled: integer("temperature_enabled", {
mode: "boolean"
}).default(true),
// Tuned defaults for roleplay:
// More creative and less repetitive
temperature: real("temperature").default(0.7), // Higher = more creative
temperatureEnabled: integer("temperature_enabled", { mode: "boolean" }).default(true),
topP: real("top_p").default(0.92), // Lower than 1, encourages diversity but not too random
topPEnabled: integer("top_p_enabled", { mode: "boolean" }).default(true),
topP: real("top_p").default(0.92), // Lower than 1, encourages diversity but not too random
topPEnabled: integer("top_p_enabled", { mode: "boolean" }).default(true),
topK: integer("top_k").default(80), // Allows more token options for creative replies
topKEnabled: integer("top_k_enabled", { mode: "boolean" }).default(true),
topK: integer("top_k").default(80), // Allows more token options for creative replies
topKEnabled: integer("top_k_enabled", { mode: "boolean" }).default(true),
repetitionPenalty: real("repetition_penalty").default(1.15), // Slightly encourages less repetition but not too harsh
repetitionPenaltyEnabled: integer("repetition_penalty_enabled", {
mode: "boolean"
}).default(true),
repetitionPenalty: real("repetition_penalty").default(1.15), // Slightly encourages less repetition but not too harsh
repetitionPenaltyEnabled: integer("repetition_penalty_enabled", { mode: "boolean" }).default(
true
),
frequencyPenalty: real("frequency_penalty").default(0.2), // Mild penalty for repetitive phrases
frequencyPenaltyEnabled: integer("frequency_penalty_enabled", {
mode: "boolean"
}).default(true),
frequencyPenalty: real("frequency_penalty").default(0.2), // Mild penalty for repetitive phrases
frequencyPenaltyEnabled: integer("frequency_penalty_enabled", { mode: "boolean" }).default(
true
),
presencePenalty: real("presence_penalty").default(0.6), // Encourage new topics and freshness
presencePenaltyEnabled: integer("presence_penalty_enabled", {
mode: "boolean"
}).default(true),
presencePenalty: real("presence_penalty").default(0.6), // Encourage new topics and freshness
presencePenaltyEnabled: integer("presence_penalty_enabled", { mode: "boolean" }).default(true),
responseTokens: integer("response_tokens").default(512), // Allow longer, richer replies
responseTokensEnabled: integer("response_tokens_enabled", {
mode: "boolean"
}).default(true),
responseTokensUnlocked: integer("response_tokens_unlocked", {
mode: "boolean"
}).default(false), // Dynamic length allowed
responseTokens: integer("response_tokens").default(512), // Allow longer, richer replies
responseTokensEnabled: integer("response_tokens_enabled", { mode: "boolean" }).default(true),
responseTokensUnlocked: integer("response_tokens_unlocked", { mode: "boolean" }).default(false), // Dynamic length allowed
contextTokens: integer("context_tokens").default(4096), // Keep more conversation in memory/context
contextTokensEnabled: integer("context_tokens_enabled", {
mode: "boolean"
}).default(true),
contextTokensUnlocked: integer("context_tokens_unlocked", {
mode: "boolean"
}).default(false), // Allow for context window expansion
contextTokens: integer("context_tokens").default(4096), // Keep more conversation in memory/context
contextTokensEnabled: integer("context_tokens_enabled", { mode: "boolean" }).default(true),
contextTokensUnlocked: integer("context_tokens_unlocked", { mode: "boolean" }).default(false), // Allow for context window expansion
seed: integer("seed").default(-1), // -1 for random, can be used for deterministic sampling
seedEnabled: integer("seed_enabled", { mode: "boolean" }).default(false)
seed: integer("seed").default(-1), // -1 for random, can be used for deterministic sampling
seedEnabled: integer("seed_enabled", { mode: "boolean" }).default(false)
})
export const samplingRelations = relations(samplingConfigs, () => ({}))
export const connections = sqliteTable("connections", {
id: integer("id").primaryKey(),
name: text("name").notNull(), // Connection name (e.g., ollama, llama, chatgpt)
type: text("type").notNull(), // Connection type/category (e.g., ollama, chatgpt, etc)
baseUrl: text("base_url"), // Base URL or endpoint for API
model: text("model"), // Model name or identifier
// Ollama-specific options
extraJson: text("extra_json", { mode: "json" }).$type<
Record<string, any>
>(), // Additional JSON options for the connections, api keys, etc.
tokenCounter: text("token_counter").notNull().default("estimate"),
promptFormat: text("prompt_format").default("vicuna")
id: integer("id").primaryKey(),
name: text("name").notNull(), // Connection name (e.g., ollama, llama, chatgpt)
type: text("type").notNull(), // Connection type/category (e.g., ollama, chatgpt, etc)
baseUrl: text("base_url"), // Base URL or endpoint for API
model: text("model"), // Model name or identifier
// Ollama-specific options
extraJson: text("extra_json", { mode: "json" }).$type<Record<string, any>>(), // Additional JSON options for the connections, api keys, etc.
tokenCounter: text("token_counter").notNull().default("estimate"),
promptFormat: text("prompt_format").default("vicuna")
})
export const connectionsRelations = relations(connections, () => ({}))
export const contextConfigs = sqliteTable("context_configs", {
id: integer("id").primaryKey(),
isImmutable: integer("is_immutable", { mode: "boolean" }).default(true),
name: text("name").notNull(),
template: text("template"), // Sillytavern storyString
alwaysForceName: integer("always_force_name", { mode: "boolean" }).default(
true
) // Always force name2
id: integer("id").primaryKey(),
isImmutable: integer("is_immutable", { mode: "boolean" }).default(true),
name: text("name").notNull(),
template: text("template"), // Sillytavern storyString
alwaysForceName: integer("always_force_name", { mode: "boolean" }).default(true) // Always force name2
})
export const contextConfigsRelations = relations(contextConfigs, () => ({}))
export const promptConfigs = sqliteTable("prompt_configs", {
id: integer("id").primaryKey(),
isImmutable: integer("is_immutable", { mode: "boolean" }).default(true),
name: text("name").notNull(),
systemPrompt: text("system_prompt").notNull() // Maps to sillytavern sysPrompt.content
id: integer("id").primaryKey(),
isImmutable: integer("is_immutable", { mode: "boolean" }).default(true),
name: text("name").notNull(),
systemPrompt: text("system_prompt").notNull() // Maps to sillytavern sysPrompt.content
})
export const promptConfigsRelations = relations(promptConfigs, () => ({}))
export const lorebooks = sqliteTable("lorebooks", {
id: integer("id").primaryKey(),
name: text("name").notNull(),
description: text("description").notNull().default(""),
extraJson: text("extra_json", { mode: "json" })
.notNull()
.default({})
.$type<Record<string, any>>(),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }), // FK to users.id
createdAt: text("created_at").default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text("updated_at").$onUpdate(() => sql`(CURRENT_TIMESTAMP)`)
id: integer("id").primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }), // FK to users.id
name: text("name").notNull(), // Lorebook name
description: text("description"), // Lorebook description
tags: text("tags"), // JSON array of tags
entries: text("entries"), // JSON array of lorebook entries (for compatibility with SillyTavern)
metadata: text("metadata"), // JSON object for any extra SillyTavern/world/lorebook fields
createdAt: text("created_at"), // ISO date string
updatedAt: text("updated_at") // ISO date string
})
export const lorebooksRelations = relations(lorebooks, ({ many, one }) => ({
worldLoreEntries: many(worldLoreEntries),
characterLoreEntries: many(characterLoreEntries),
historyEntries: many(historyEntries),
user: one(users, {
fields: [lorebooks.userId],
references: [users.id]
}),
lorebookBindings: many(lorebookBindings)
entries: many(lorebookEntries),
user: one(users, {
fields: [lorebooks.userId],
references: [users.id]
})
}))
export const lorebookBindings = sqliteTable(
"lorebook_bindings",
{
id: integer("id").primaryKey(),
lorebookId: integer("lorebook_id")
.notNull()
.references(() => lorebooks.id, { onDelete: "cascade" }),
characterId: integer("character_id").references(() => characters.id, {
onDelete: "set null"
}),
personaId: integer("persona_id").references(() => personas.id, {
onDelete: "set null"
}),
binding: text("binding").notNull() // e.g. "{char:1}"
},
(table) => ({
uniqueBinding: uniqueIndex("lorebook_bindings_unique").on(
table.lorebookId,
table.characterId,
table.personaId
)
})
)
export const lorebookBindingsRelations = relations(
lorebookBindings,
({ one, many }) => ({
lorebook: one(lorebooks, {
fields: [lorebookBindings.lorebookId],
references: [lorebooks.id]
}),
character: one(characters, {
fields: [lorebookBindings.characterId],
references: [characters.id]
}),
persona: one(personas, {
fields: [lorebookBindings.personaId],
references: [personas.id]
}),
characterLoreEntries: many(characterLoreEntries)
})
)
export const worldLoreEntries = sqliteTable("world_lore_entries", {
id: integer("id").primaryKey(),
lorebookId: integer("lorebook_id")
.notNull()
.references(() => lorebooks.id, { onDelete: "cascade" }),
name: text("name").notNull(),
category: text("category"),
keys: text("keys").notNull().default(""),
useRegex: integer("use_regex", { mode: "boolean" }).default(false),
caseSensitive: integer("case_sensitive", { mode: "boolean" })
.notNull()
.default(false),
content: text("content").notNull().default(""),
priority: integer("priority").notNull().default(1),
constant: integer("constant", { mode: "boolean" }).notNull().default(false),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
extraJson: text("extra_json", { mode: "json" })
.notNull()
.default({})
.$type<Record<string, any>>(),
createdAt: text("created_at").default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text("updated_at").$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
position: integer("position").notNull().default(0)
export const lorebookEntries = sqliteTable("lorebook_entries", {
id: integer("id").primaryKey(),
lorebookId: integer("lorebook_id")
.notNull()
.references(() => lorebooks.id, { onDelete: "cascade" }), // FK to lorebooks.id
key: text("key"), // JSON array of keys
keySecondary: text("key_secondary"), // JSON array of secondary keys
comment: text("comment"),
content: text("content"),
constant: integer("constant", { mode: "boolean" }).default(false), // Is this entry a constant value?
vectorized: integer("vectorized"),
selective: integer("selective"),
selectiveLogic: integer("selective_logic"),
addMemo: integer("add_memo"),
order: integer("order"),
position: integer("position"),
disable: integer("disable", { mode: "boolean" }).default(false), // Is this entry disabled?
excludeRecursion: integer("exclude_recursion"),
preventRecursion: integer("prevent_recursion"),
delayUntilRecursion: integer("delay_until_recursion"),
probability: integer("probability"),
useProbability: integer("use_probability"),
depth: integer("depth"),
group: text("group"),
groupOverride: integer("group_override"),
groupWeight: integer("group_weight"),
scanDepth: integer("scan_depth"),
caseSensitive: integer("case_sensitive"),
matchWholeWords: integer("match_whole_words"),
useGroupScoring: integer("use_group_scoring"),
automationId: text("automation_id"),
role: text("role"),
sticky: integer("sticky"),
cooldown: integer("cooldown"),
delay: integer("delay"),
displayIndex: integer("display_index")
})
export const worldLoreEntriesRelations = relations(
worldLoreEntries,
({ one }) => ({
lorebook: one(lorebooks, {
fields: [worldLoreEntries.lorebookId],
references: [lorebooks.id]
})
})
)
export const characterLoreEntries = sqliteTable("character_lore_entries", {
id: integer("id").primaryKey(),
lorebookId: integer("lorebook_id")
.notNull()
.references(() => lorebooks.id, { onDelete: "cascade" }),
lorebookBindingId: integer("character_binding_id").references(
() => lorebookBindings.id,
{ onDelete: "set null" }
),
name: text("name"),
keys: text("keys").notNull().default(""),
useRegex: integer("use_regex", { mode: "boolean" }).default(false),
caseSensitive: integer("case_sensitive", { mode: "boolean" })
.notNull()
.default(false),
content: text("content").notNull().default(""),
priority: integer("priority").notNull().default(1),
constant: integer("constant", { mode: "boolean" }).notNull().default(false),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
extraJson: text("extra_json", { mode: "json" })
.notNull()
.default({})
.$type<Record<string, any>>(),
createdAt: text("created_at").default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text("updated_at").$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
position: integer("position").notNull().default(0)
})
export const characterLoreEntriesRelations = relations(
characterLoreEntries,
({ one }) => ({
lorebook: one(lorebooks, {
fields: [characterLoreEntries.lorebookId],
references: [lorebooks.id]
}),
lorebookBinding: one(lorebookBindings, {
fields: [characterLoreEntries.lorebookBindingId],
references: [lorebookBindings.id]
})
})
)
export const historyEntries = sqliteTable("history_entries", {
id: integer("id").primaryKey(),
lorebookId: integer("lorebook_id")
.notNull()
.references(() => lorebooks.id, { onDelete: "cascade" }),
date: text("date", { mode: "json" })
.notNull()
.default({ day: 1, month: 1, year: 1 })
.$type<{ day: number | null; month: number | null; year: number }>(),
keys: text("keys").notNull().default(""),
useRegex: integer("use_regex", { mode: "boolean" }).default(false),
caseSensitive: integer("case_sensitive", { mode: "boolean" })
.notNull()
.default(false),
content: text("content").notNull().default(""),
constant: integer("constant", { mode: "boolean" }).notNull().default(false),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
extraJson: text("extra_json", { mode: "json" })
.notNull()
.default({})
.$type<Record<string, any>>(),
createdAt: text("created_at").default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text("updated_at").$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
position: integer("position").notNull().default(0)
})
export const historyEntriesRelations = relations(historyEntries, ({ one }) => ({
lorebook: one(lorebooks, {
fields: [historyEntries.lorebookId],
references: [lorebooks.id]
})
export const lorebookEntriesRelations = relations(lorebookEntries, ({ one }) => ({
lorebook: one(lorebooks, {
fields: [lorebookEntries.lorebookId],
references: [lorebooks.id]
})
}))
export const tags = sqliteTable("tags", {
id: integer("id").primaryKey(),
name: text("name").notNull(), // Tag name (unique)
description: text("description")
id: integer("id").primaryKey(),
name: text("name").notNull(), // Tag name (unique)
description: text("description")
})
export const tagsRelations = relations(tags, ({ many }) => ({
characterTags: many(characterTags)
characterTags: many(characterTags)
}))
export const characterTags = sqliteTable("character_tags", {
characterId: integer("character_id")
.notNull()
.references(() => characters.id, { onDelete: "cascade" }), // FK to characters.id
tagId: integer("tag_id")
.notNull()
.references(() => tags.id, { onDelete: "cascade" }) // FK to tags.id
characterId: integer("character_id")
.notNull()
.references(() => characters.id, { onDelete: "cascade" }), // FK to characters.id
tagId: integer("tag_id")
.notNull()
.references(() => tags.id, { onDelete: "cascade" }) // FK to tags.id
})
export const characterTagsRelations = relations(characterTags, ({ one }) => ({
character: one(characters, {
fields: [characterTags.characterId],
references: [characters.id]
}),
tag: one(tags, {
fields: [characterTags.tagId],
references: [tags.id]
})
character: one(characters, {
fields: [characterTags.characterId],
references: [characters.id]
}),
tag: one(tags, {
fields: [characterTags.tagId],
references: [tags.id]
})
}))
export const characters = sqliteTable("characters", {
id: integer("id").primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }), // FK to users.id
name: text("name").notNull(),
nickname: text("nickname"), // Optional nickname
characterVersion: text("character_version").default("1.0"), // Version of the character schema
description: text("description").notNull(),
personality: text("personality"), // Persona field
scenario: text("scenario"),
firstMessage: text("first_message"),
alternateGreetings: text("alternate_greetings", { mode: "json" })
.default("[]")
.$type<string[]>(), // JSON array of alternate greetings
exampleDialogues: text("example_dialogues"), // JSON/text
metadata: text("metadata", { mode: "json" }), // JSON/text for extra fields
avatar: text("avatar"), // Path or URL to avatar image
creatorNotes: text("creator_notes"), // Notes from the character creator
creatorNotesMultilingual: text("creator_notes_multilingual", {
mode: "json"
})
.default("{}")
.$type<Record<string, string>>(), // Multilingual creator notes as JSON object
groupOnlyGreetings: text("group_only_greetings", { mode: "json" })
.default("[]")
.$type<String[]>(), // JSON array of greetings for group chats
postHistoryInstructions: text("post_history_instructions"), // Instructions for post-history processing
source: text("source", { mode: "json" }).default("[]").$type<string[]>(), // JSON array of sources (e.g., URLs, books)
assets: text("assets", { mode: "json" }).default("[]").$type<
Array<{
type: string
uri: string
name: string
ext: string
}>
>(), // JSON array of asset paths or URLs
createdAt: text("created_at").default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text("updated_at").$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
lorebookId: integer("lorebook_id").references(() => lorebooks.id, {
onDelete: "set null"
}), // Optional FK to lorebooks.id
extensions: text("extensions", { mode: "json" })
.default("[]")
.$type<Record<string, any>>(),
isFavorite: integer("is_favorite", { mode: "boolean" }).default(false) // 1 if favorite, 0 otherwise
id: integer("id").primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }), // FK to users.id
name: text("name").notNull(),
nickname: text("nickname"), // Optional nickname
characterVersion: text("character_version").default("1.0"), // Version of the character schema
description: text("description").notNull(),
personality: text("personality"), // Persona field
scenario: text("scenario"),
firstMessage: text("first_message"),
alternateGreetings: text("alternate_greetings", { mode: "json" })
.default("[]")
.$type<string[]>(), // JSON array of alternate greetings
exampleDialogues: text("example_dialogues"), // JSON/text
metadata: text("metadata"), // JSON/text for extra fields
avatar: text("avatar"), // Path or URL to avatar image
creatorNotes: text("creator_notes"), // Notes from the character creator
creatorNotesMultilingual: text("creator_notes_multilingual", { mode: "json" })
.default("{}")
.$type<Record<string, string>>(), // Multilingual creator notes as JSON object
groupOnlyGreetings: text("group_only_greetings", { mode: "json" })
.default("[]")
.$type<String[]>(), // JSON array of greetings for group chats
postHistoryInstructions: text("post_history_instructions"), // Instructions for post-history processing
source: text("source", { mode: "json" }).default("[]").$type<string[]>(), // JSON array of sources (e.g., URLs, books)
assets: text("assets", { mode: "json" }).default("[]").$type<
Array<{
type: string
uri: string
name: string
ext: string
}>
>(), // JSON array of asset paths or URLs
createdAt: text("created_at").default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text("updated_at").$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
lorebookId: integer("lorebook_id").references(() => lorebooks.id, { onDelete: "set null" }), // Optional FK to lorebooks.id
extensions: text("extensions", { mode: "json" }).default("[]").$type<Record<string, any>>(),
isFavorite: integer("is_favorite", { mode: "boolean" }).default(false) // 1 if favorite, 0 otherwise
})
export const charactersRelations = relations(characters, ({ many, one }) => ({
characterTags: many(characterTags),
user: one(users, {
fields: [characters.userId],
references: [users.id]
}),
lorebook: one(lorebooks, {
fields: [characters.lorebookId],
references: [lorebooks.id]
})
characterTags: many(characterTags),
user: one(users, {
fields: [characters.userId],
references: [users.id]
}),
lorebook: one(lorebooks, {
fields: [characters.lorebookId],
references: [lorebooks.id]
})
}))
export const personas = sqliteTable("personas", {
id: integer("id").primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }), // FK to users.id
isDefault: integer("is_default", { mode: "boolean" }).default(false), // Is this the default persona for the user?
avatar: text("avatar"), // e.g. 'user-default.png', '1747379438925-Ryvn.png'
name: text("name").notNull(), // e.g. 'Warren', 'Master Desir'
description: text("description").notNull(), // Persona description (long text)
position: integer("position").default(0),
connections: text("connections"), // JSON array of connection IDs or objects
createdAt: text("created_at"),
updatedAt: text("updated_at"),
lorebookId: integer("lorebook_id").references(() => lorebooks.id, {
onDelete: "set null"
}) // Optional lorebook for this persona
id: integer("id").primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }), // FK to users.id
isDefault: integer("is_default", { mode: "boolean" }).default(false), // Is this the default persona for the user?
avatar: text("avatar"), // e.g. 'user-default.png', '1747379438925-Ryvn.png'
name: text("name").notNull(), // e.g. 'Warren', 'Master Desir'
description: text("description").notNull(), // Persona description (long text)
position: integer("position").default(0),
connections: text("connections"), // JSON array of connection IDs or objects
createdAt: text("created_at"),
updatedAt: text("updated_at")
})
export const personasRelations = relations(personas, ({ one, many }) => ({
user: one(users, {
fields: [personas.userId],
references: [users.id]
}),
lorebook: one(lorebooks, {
fields: [personas.lorebookId],
references: [lorebooks.id]
})
user: one(users, {
fields: [personas.userId],
references: [users.id]
})
}))
// Chats (group or 1:1)
export const chats = sqliteTable("chats", {
id: integer("id").primaryKey(),
name: text("name"), // Optional chat/group name
isGroup: integer("is_group", { mode: "boolean" }).default(false), // 1 for group chat, 0 for 1:1
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: text("created_at"),
updatedAt: text("updated_at"),
scenario: text("scenario"),
metadata: text("metadata"), // JSON for extra settings
groupReplyStrategy: text("group_reply_strategy").default(
GroupReplyStrategies.ORDERED
),
lorebookId: integer("lorebook_id").references(() => lorebooks.id, {
onDelete: "set null"
}) // Primary lorebook for this chat
id: integer("id").primaryKey(),
name: text("name"), // Optional chat/group name
isGroup: integer("is_group", {mode: "boolean"}).default(false), // 1 for group chat, 0 for 1:1
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: text("created_at"),
updatedAt: text("updated_at"),
scenario: text("scenario"),
metadata: text("metadata"), // JSON for extra settings
group_reply_strategy: text("group_reply_strategy").default(GroupReplyStrategies.ORDERED)
})
export const chatsRelations = relations(chats, ({ one, many }) => ({
user: one(users, {
fields: [chats.userId],
references: [users.id]
}),
chatMessages: many(chatMessages),
chatPersonas: many(chatPersonas),
chatCharacters: many(chatCharacters),
lorebook: one(lorebooks, {
fields: [chats.lorebookId],
references: [lorebooks.id]
})
user: one(users, {
fields: [chats.userId],
references: [users.id]
}),
chatMessages: many(chatMessages),
chatPersonas: many(chatPersonas),
chatCharacters: many(chatCharacters)
}))
// Chat messages
export const chatMessages = sqliteTable("chat_messages", {
id: integer("id").primaryKey(),
chatId: integer("chat_id")
.notNull()
.references(() => chats.id, { onDelete: "cascade" }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }), // nullable for system/character messages
characterId: integer("character_id").references(() => characters.id, {
onDelete: "set null"
}), // nullable
personaId: integer("persona_id").references(() => personas.id, {
onDelete: "set null"
}), // nullable
role: text("role"), // 'user', 'character', 'system', etc
content: text("content").notNull(),
createdAt: text("created_at"),
updatedAt: text("updated_at"),
isEdited: integer("is_edited").default(0), // 1 if edited, 0 otherwise
metadata: text("metadata", { mode: "json" }).$type<{isGreeting?: boolean, swipes?:{currentIdx: number | null, history: []}}>(), // JSON for extra info
isGenerating: integer("is_generating", { mode: "boolean" }).default(false), // 1 if processing, 0 otherwise
adapterId: text("adapter_id"), // UUID for in-flight adapter instance, nullable
isHidden: integer("is_hidden", { mode: "boolean" }).default(false) // Whether this message is processed or not
id: integer("id").primaryKey(),
chatId: integer("chat_id")
.notNull()
.references(() => chats.id, { onDelete: "cascade" }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }), // nullable for system/character messages
characterId: integer("character_id").references(() => characters.id, { onDelete: "set null" }), // nullable
personaId: integer("persona_id").references(() => personas.id, { onDelete: "set null" }), // nullable
role: text("role"), // 'user', 'character', 'system', etc
content: text("content").notNull(),
createdAt: text("created_at"),
updatedAt: text("updated_at"),
isEdited: integer("is_edited").default(0), // 1 if edited, 0 otherwise
metadata: text("metadata"), // JSON for extra info
isGenerating: integer("is_generating", { mode: "boolean" }).default(false), // 1 if processing, 0 otherwise
adapterId: text("adapter_id"), // UUID for in-flight adapter instance, nullable
isHidden: integer("is_hidden", { mode: "boolean" }).default(false) // Whether this message is processed or not
})
export const chatMessagesRelations = relations(chatMessages, ({ one }) => ({
chat: one(chats, {
fields: [chatMessages.chatId],
references: [chats.id]
}),
user: one(users, {
fields: [chatMessages.userId],
references: [users.id]
}),
character: one(characters, {
fields: [chatMessages.characterId],
references: [characters.id]
}),
persona: one(personas, {
fields: [chatMessages.personaId],
references: [personas.id]
})
chat: one(chats, {
fields: [chatMessages.chatId],
references: [chats.id]
}),
user: one(users, {
fields: [chatMessages.userId],
references: [users.id]
}),
character: one(characters, {
fields: [chatMessages.characterId],
references: [characters.id]
}),
persona: one(personas, {
fields: [chatMessages.personaId],
references: [personas.id]
})
}))
// Many-to-many: chats <-> personas
export const chatPersonas = sqliteTable("chat_personas", {
chatId: integer("chat_id")
.notNull()
.references(() => chats.id, { onDelete: "cascade" }),
personaId: integer("persona_id").references(() => personas.id, {
onDelete: "set null"
}),
position: integer("position").default(0) // Position in the chat
chatId: integer("chat_id")
.notNull()
.references(() => chats.id, { onDelete: "cascade" }),
personaId: integer("persona_id")
.references(() => personas.id, { onDelete: "set null" }),
position: integer("position").default(0), // Position in the chat
})
export const chatPersonasRelations = relations(chatPersonas, ({ one }) => ({
chat: one(chats, {
fields: [chatPersonas.chatId],
references: [chats.id]
}),
persona: one(personas, {
fields: [chatPersonas.personaId],
references: [personas.id]
})
chat: one(chats, {
fields: [chatPersonas.chatId],
references: [chats.id]
}),
persona: one(personas, {
fields: [chatPersonas.personaId],
references: [personas.id]
})
}))
// Many-to-many: chats <-> characters
export const chatCharacters = sqliteTable("chat_characters", {
chatId: integer("chat_id")
.notNull()
.references(() => chats.id, { onDelete: "cascade" }),
characterId: integer("character_id").references(() => characters.id, {
onDelete: "set null"
}),
position: integer("position").default(0), // Position in the chat
isActive: integer("is_active", { mode: "boolean" }).default(true) // 1 if active in chat, 0 if not
chatId: integer("chat_id")
.notNull()
.references(() => chats.id, { onDelete: "cascade" }),
characterId: integer("character_id")
.references(() => characters.id, { onDelete: "set null" }),
position: integer("position").default(0), // Position in the chat
isActive: integer("is_active", { mode: "boolean" }).default(true) // 1 if active in chat, 0 if not
})
export const chatCharactersRelations = relations(chatCharacters, ({ one }) => ({
chat: one(chats, {
fields: [chatCharacters.chatId],
references: [chats.id]
}),
character: one(characters, {
fields: [chatCharacters.characterId],
references: [characters.id]
})
}))
// Many-to-many: chats <-> lorebooks
export const chatLorebooks = sqliteTable(
"chat_lorebooks",
{
chatId: integer("chat_id")
.notNull()
.references(() => chats.id, { onDelete: "cascade" }),
lorebookId: integer("lorebook_id")
.notNull()
.references(() => lorebooks.id, { onDelete: "cascade" }),
position: integer("position").default(0) // Optional: position/order in the chat
},
(table) => ({
uniqueChatPosition: uniqueIndex(
"chatLorebooks_chatId_position_unique"
).on(table.chatId, table.position)
})
)
export const chatLorebooksRelations = relations(chatLorebooks, ({ one }) => ({
chat: one(chats, {
fields: [chatLorebooks.chatId],
references: [chats.id]
}),
lorebook: one(lorebooks, {
fields: [chatLorebooks.lorebookId],
references: [lorebooks.id]
})
}))
chat: one(chats, {
fields: [chatCharacters.chatId],
references: [chats.id]
}),
character: one(characters, {
fields: [chatCharacters.characterId],
references: [characters.id]
})
}))

View file

@ -657,8 +657,9 @@ export async function triggerGenerateMessage(
const msgLimit = 10
let currentMsg = 1
let triggered = true
let ok = true
while (currentMsg <= msgLimit) {
while (currentMsg <= msgLimit && ok) {
let chat = await getPromptChatFromDb(message.chatId, userId)
if (!chat) {
const res: Sockets.TriggerGenerateMessage.Response = {
@ -710,7 +711,7 @@ export async function triggerGenerateMessage(
{ chatMessage: generatingMessage },
emitToUser
)
await generateResponse({
ok =await generateResponse({
socket,
emitToUser,
chatId: message.chatId,

View file

@ -18,7 +18,7 @@ export async function generateResponse({
chatId: number
userId: number
generatingMessage: SelectChatMessage
}) {
}): Promise<boolean> {
// Generate a UUID for this adapter instance
const adapterId = uuidv4()
// Save the adapterId to the chatMessage (set isGenerating true, content empty, and adapterId)
@ -28,8 +28,6 @@ export async function generateResponse({
.where(eq(schema.chatMessages.id, generatingMessage.id))
// Instead of getChat, emit the chatMessage
// console.log("[generateResponse] Generating response for message:", generatingMessage.id, "in chat:", chatId, "for user:", userId, "with adapterId:", adapterId)
const req: Sockets.ChatMessage.Call = {
chatMessage: {
...generatingMessage,
@ -39,11 +37,7 @@ export async function generateResponse({
}
}
await chatMessage(
socket,
req,
emitToUser
)
await chatMessage(socket, req, emitToUser)
const chat = await db.query.chats.findFirst({
where: (c, { eq }) => eq(c.id, chatId),
@ -54,7 +48,7 @@ export async function generateResponse({
where: (cm, { ne }) => ne(cm.id, generatingMessage.id),
orderBy: (cm, { asc }) => asc(cm.id)
},
lorebook: true,
lorebook: true
}
})
@ -89,11 +83,16 @@ export async function generateResponse({
activeAdapters.set(adapterId, adapter)
// Generate completion
let [completionResult, compiledPrompt] = await adapter.generate() // TODO: save compiledPrompt to chatMessages
let { completionResult, compiledPrompt, isAborted } =
await adapter.generate() // TODO: save compiledPrompt to chatMessages
let content = ""
try {
if (typeof completionResult === "function") {
let ok = true
await completionResult(async (chunk: string) => {
if (!ok) {
return
}
content += chunk
// --- SWIPE HISTORY LOGIC ---
@ -101,12 +100,15 @@ export async function generateResponse({
if (
generatingMessage.metadata &&
generatingMessage.metadata.swipes &&
typeof generatingMessage.metadata.swipes.currentIdx === "number" &&
typeof generatingMessage.metadata.swipes.currentIdx ===
"number" &&
generatingMessage.metadata.swipes.currentIdx > 0 &&
Array.isArray(generatingMessage.metadata.swipes.history)
) {
const idx = generatingMessage.metadata.swipes.currentIdx
const history: string[] = [...generatingMessage.metadata.swipes.history]
const history: string[] = [
...generatingMessage.metadata.swipes.history
]
history[idx] = content
updateData = {
...updateData,
@ -120,34 +122,52 @@ export async function generateResponse({
}
}
await db
const [updatedChatMsg] = await db
.update(schema.chatMessages)
.set(updateData)
.where(eq(schema.chatMessages.id, generatingMessage.id))
// Instead of getChat, emit the chatMessage
await chatMessage(
socket,
{
chatMessage: {
...generatingMessage,
content,
isGenerating: true,
...(updateData.metadata ? { metadata: updateData.metadata } : {})
}
},
emitToUser
)
.where(
and(
eq(schema.chatMessages.id, generatingMessage.id),
eq(schema.chatMessages.isGenerating, true)
)
)
.returning()
if (!!updatedChatMsg) {
const chatMsgReq: Sockets.ChatMessage.Call = {
chatMessage: updatedChatMsg
}
await chatMessage(socket, chatMsgReq, emitToUser)
} else {
const chatMsgReq: Sockets.ChatMessage.Call = {
id: generatingMessage.id
}
await chatMessage(socket, chatMsgReq, emitToUser)
console.warn(
"[generateResponse] Generating terminated early",
generatingMessage.id
)
ok = false
}
})
// Final update: mark as not generating, clear adapterId
content = content.trim()
const ret = await db
.update(schema.chatMessages)
.set({ content, isGenerating: false, adapterId: null })
.where(and(eq(schema.chatMessages.id, generatingMessage.id), eq(schema.chatMessages.isGenerating, true))).returning()
.where(
and(
eq(schema.chatMessages.id, generatingMessage.id),
eq(schema.chatMessages.isGenerating, true)
)
)
.returning()
if (!ret || ret.length === 0) {
console.error("[generateResponse] Failed to update generating message:", generatingMessage.id)
console.error(
"[generateResponse] Failed to update generating message:",
generatingMessage.id
)
activeAdapters.delete(adapterId)
return
return false
}
// Instead of getChat, emit the chatMessage
await chatMessage(
@ -167,16 +187,23 @@ export async function generateResponse({
content = content.trim()
// --- SWIPE HISTORY LOGIC (non-streamed) ---
let updateData: any = { content, isGenerating: false, adapterId: null }
let updateData: any = {
content,
isGenerating: false,
adapterId: null
}
if (
generatingMessage.metadata &&
generatingMessage.metadata.swipes &&
typeof generatingMessage.metadata.swipes.currentIdx === "number" &&
typeof generatingMessage.metadata.swipes.currentIdx ===
"number" &&
generatingMessage.metadata.swipes.currentIdx > 0 &&
Array.isArray(generatingMessage.metadata.swipes.history)
) {
const idx = generatingMessage.metadata.swipes.currentIdx
const history: string[] = [...generatingMessage.metadata.swipes.history]
const history: string[] = [
...generatingMessage.metadata.swipes.history
]
history[idx] = content
updateData = {
...updateData,
@ -193,12 +220,21 @@ export async function generateResponse({
const ret = await db
.update(schema.chatMessages)
.set(updateData)
.where(and(eq(schema.chatMessages.id, generatingMessage.id), eq(schema.chatMessages.isGenerating, true))).returning()
.where(
and(
eq(schema.chatMessages.id, generatingMessage.id),
eq(schema.chatMessages.isGenerating, true)
)
)
.returning()
// Instead of getChat, emit the chatMessage
if (!ret || ret.length === 0) {
console.error("[generateResponse] Failed to update generating message:", generatingMessage.id)
console.error(
"[generateResponse] Failed to update generating message:",
generatingMessage.id
)
activeAdapters.delete(adapterId)
return
return false
}
await chatMessage(
socket,
@ -208,7 +244,9 @@ export async function generateResponse({
content,
isGenerating: false,
adapterId: null,
...(updateData.metadata ? { metadata: updateData.metadata } : {})
...(updateData.metadata
? { metadata: updateData.metadata }
: {})
}
},
emitToUser
@ -228,4 +266,5 @@ export async function generateResponse({
socket.io.to("user_" + userId).emit("personaMessageReceived", response)
// Instead of getChat, emit the chatMessage
await chatMessage(socket, { chatMessage: updatedMsg! }, emitToUser)
return !!isAborted // Whether there were no interruptions
}

View file

@ -167,14 +167,21 @@
let chatMessagesContainer: HTMLDivElement | null = $state(null)
// Auto-scroll to bottom when messages change or container is mounted
$effect(() => {
console.log(
"chatMessagesContainer effect",
chatMessagesContainer?.scrollTo({
top: chatMessagesContainer.scrollHeight,
behavior: "auto"
})
)
// React to changes in messages and container
const messagesLength = chat?.chatMessages?.length ?? 0
if (chatMessagesContainer && messagesLength > 0) {
// Use setTimeout to ensure DOM has updated
setTimeout(() => {
if (chatMessagesContainer) {
chatMessagesContainer.scrollTo({
top: chatMessagesContainer.scrollHeight,
behavior: "smooth"
})
}
}, 50) // Slightly longer delay to ensure content is rendered
}
})
function handleEditMessage(e: Event, msg: SelectChatMessage) {
@ -315,12 +322,7 @@
socket.on("chat", (msg: Sockets.Chat.Response) => {
if (msg.chat.id === Number.parseInt(page.params.id)) {
chat = msg.chat
// Instantly jump to bottom on chat update
console.log("Scrolling to bottom on chat update")
chatMessagesContainer?.scrollTo({
top: chatMessagesContainer.scrollHeight,
behavior: "instant"
})
// Auto-scroll is handled by the $effect
}
})
@ -339,12 +341,7 @@
chatMessages: [...chat.chatMessages, msg.chatMessage]
}
}
setTimeout(() => {
chatMessagesContainer?.scrollTo({
top: chatMessagesContainer.scrollHeight,
behavior: "smooth"
})
}, 0)
// Auto-scroll is handled by the $effect
}
})
@ -415,7 +412,7 @@
</svelte:head>
<div
class="relative flex max-h-full min-h-full max-w-full min-w-full flex-col overflow-y-auto"
class="relative flex h-full flex-col"
>
<div
id="chat-history"
@ -426,12 +423,12 @@
{#if !chat || chat.chatMessages.length === 0}
<div class="text-muted mt-8 text-center">No messages yet.</div>
{:else}
<ul class="flex flex-1 flex-col gap-3 overflow-y-auto">
<ul class="flex flex-1 flex-col gap-3">
{#each chat.chatMessages as msg (msg.id)}
{@const character = getMessageCharacter(msg)}
{@const isGreeting = !!msg.metadata?.isGreeting}
<li
class="bg-primary-50-950 flex flex-col overflow-y-auto rounded-lg p-2"
class="preset-filled-primary-50-950 flex flex-col rounded-lg p-2"
class:opacity-50={msg.isHidden &&
editChatMessage?.id !== msg.id}
>
@ -633,6 +630,7 @@
onSend={handleSend}
compiledPrompt={draftCompiledPrompt}
classes=""
extraTabs={[
{
value: "extraControls",