Initital Summarization

This commit is contained in:
doolijb 2026-06-15 01:13:47 -07:00
parent e43ed8f2ed
commit 8ea1be072b
29 changed files with 2654 additions and 71 deletions

View file

@ -1,12 +1,16 @@
{
"permissions": {
"allow": [
"Bash(npm run lint)",
"Bash(npm run format:*)",
"Bash(npm run check:*)",
"Bash(mkdir:*)",
"Bash(mv:*)"
],
"deny": []
}
"permissions": {
"allow": [
"Bash(npm run lint)",
"Bash(npm run format:*)",
"Bash(npm run check:*)",
"Bash(mkdir:*)",
"Bash(mv:*)",
"Bash(grep -n \"async generate\" /home/jody/github/serene-pub/src/lib/server/connectionAdapters/*.ts)",
"Bash(grep -n \"snippet MessageComponent\\\\|snippet ComposerComponent\\\\|ChatContainer\\\\|ChatComposer\\\\|PersonaSelectModal\\\\|BranchChatModal\\\\|showDeleteMessage\\\\|ChatMessage \" /home/jody/github/serene-pub/src/routes/chats/[id]/+page.svelte)",
"Bash(grep -n \"SummarizeLoreModal\\\\|showSummarizeModal\\\\|isSummarizationMode\" /home/jody/github/serene-pub/src/routes/chats/[id]/+page.svelte)",
"Bash(grep -v \"^$\")"
],
"deny": []
}
}

120
.github/workflows/build-android.yml vendored Normal file
View file

@ -0,0 +1,120 @@
name: Build Android APK
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
build-android:
name: Build Android APK
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Install dependencies
run: |
node -e "const fs = require('fs'); try { fs.unlinkSync('package-lock.json'); } catch (e) { /* ignore */ }"
npm install
- name: Prepare SvelteKit
run: npx @sveltejs/kit sync
- name: Build SvelteKit app
run: npm run build
- name: Prepare production dependencies
run: |
npm install --production
npm rebuild
- name: Prepare Android assets
run: node scripts/build-android.js
- name: Decode keystore
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
if [ -n "$KEYSTORE_BASE64" ]; then
echo "$KEYSTORE_BASE64" | base64 -d > android/app/release.keystore
echo "Keystore decoded successfully"
else
echo "No keystore found, will build unsigned APK"
fi
- name: Make gradlew executable
run: chmod +x android/gradlew
- name: Build Release APK
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
env:
KEYSTORE_FILE: release.keystore
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
working-directory: android
run: |
if [ -f "app/release.keystore" ]; then
./gradlew assembleRelease
else
echo "Building unsigned APK (no keystore)"
./gradlew assembleRelease
fi
- name: Build Debug APK
if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/v')
working-directory: android
run: ./gradlew assembleDebug
- name: Get version from tag
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
id: get_version
run: |
VERSION=${GITHUB_REF#refs/tags/v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Rename APK
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
run: |
VERSION=${{ steps.get_version.outputs.version }}
mkdir -p dist
if [ -f "android/app/build/outputs/apk/release/app-release.apk" ]; then
cp android/app/build/outputs/apk/release/app-release.apk \
dist/serene-pub-${VERSION}-android.apk
else
echo "Release APK not found"
exit 1
fi
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: android-apk
path: |
dist/*.apk
android/app/build/outputs/apk/**/*.apk
- name: Upload to Release
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v1
with:
files: dist/*.apk
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

11
.gitignore vendored
View file

@ -68,3 +68,14 @@ DRAFT_MANUAL_EDITING_IMPLEMENTATION.md
TOOL_CALLING_IMPROVEMENTS.md
VERCEL_AI_SDK_MIGRATION.md
# Android
/android/.gradle
/android/.idea
/android/local.properties
/android/build
/android/app/build
/android/app/src/main/assets/serene-pub
*.apk
*.aab
keystore.jks

197
android/README.md Normal file
View file

@ -0,0 +1,197 @@
# Android Build
## Keystore Setup for GitHub Actions
To enable signed APK builds in GitHub Actions, you need to set up the following secrets:
### 1. Generate a Keystore (one-time setup)
```bash
keytool -genkey -v -keystore release.keystore \
-alias serene-pub \
-keyalg RSA -keysize 2048 -validity 10000 \
-storepass YourStorePassword \
-keypass YourKeyPassword
```
Answer the prompts for your organization details.
### 2. Encode the Keystore to Base64
```bash
base64 -w 0 release.keystore > release.keystore.base64
```
### 3. Add Secrets to GitHub
Go to your repository settings → Secrets and variables → Actions → New repository secret
Add these secrets:
- **`ANDROID_KEYSTORE_BASE64`**: Paste the contents of `release.keystore.base64`
- **`ANDROID_KEYSTORE_PASSWORD`**: Your store password from step 1
- **`ANDROID_KEY_ALIAS`**: `serene-pub` (or whatever you used)
- **`ANDROID_KEY_PASSWORD`**: Your key password from step 1
### 4. Cleanup
```bash
rm release.keystore release.keystore.base64
```
**IMPORTANT:** Never commit the keystore files to git!
## Building Locally
### Prerequisites
- Node.js 20+
- Java JDK 17+
### Setup (first time only)
```bash
npm run android:setup
```
### Build Debug APK
```bash
# Build app first
npm run build
# Prepare Android assets
npm run android:prepare
# Build debug APK (no signing needed)
npm run android:build:debug
```
Output: `android/app/build/outputs/apk/debug/app-debug.apk`
### Build Release APK
For signed release builds, you need a keystore. Place `release.keystore` in `android/app/` and export environment variables:
```bash
export KEYSTORE_FILE=release.keystore
export KEYSTORE_PASSWORD=YourStorePassword
export KEY_ALIAS=serene-pub
export KEY_PASSWORD=YourKeyPassword
npm run android:full
```
Output: `android/app/build/outputs/apk/release/app-release.apk`
### Full Build Command
```bash
npm run android:full
```
This runs: build → android:prepare → android:build
## Testing the APK
### Install on Device
```bash
adb install android/app/build/outputs/apk/debug/app-debug.apk
```
### View Logs
```bash
# View all logs
adb logcat
# Filter Node.js logs
adb logcat | grep NodeJS
# Filter app logs
adb logcat | grep "pub.serene"
```
## Architecture
The Android app:
1. **MainActivity.kt**: WebView wrapper, manages server lifecycle
2. **NodeService.kt**: Foreground service running Node.js process
3. **Assets**: Contains your entire Serene Pub build (node binary, app code, dependencies)
On first launch:
- Extracts all assets to app data directory (~500MB)
- Starts Node.js server on localhost:3000
- Opens WebView to localhost
## File Sizes
- APK size: ~80-100MB (compressed)
- Installed size: ~500MB
- Node.js binary: ~40MB
- node_modules: ~300MB
- App code: ~50MB
- Data/cache: grows with use
## Permissions
Required permissions (defined in AndroidManifest.xml):
- `INTERNET`: Network access
- `FOREGROUND_SERVICE`: Keep Node.js running
- `WAKE_LOCK`: Prevent CPU sleep during operations
- `CAMERA`: For character avatar uploads (optional)
- `READ/WRITE_EXTERNAL_STORAGE`: For file access on older Android versions
## Known Limitations
- Large app size due to Node.js + dependencies
- First launch is slow (asset extraction)
- Battery drain from Node.js process
- Minimum Android 8.0 (API 26) required
- ARM64 only (no x86/ARM32 support yet)
## Troubleshooting
### Gradle build fails
```bash
cd android
./gradlew clean
./gradlew assembleDebug --info
```
### App crashes on startup
Check logs: `adb logcat | grep "pub.serene"`
Common issues:
- Node binary not executable
- Assets not extracted properly
- Port 3000 already in use
### Server won't start
The app shows "Server startup timeout" if Node.js doesn't start within 30 seconds. Check:
- Logcat for error messages
- Available storage space
- Node.js binary exists at `/data/data/pub.serene.app/files/node`
## Adding to GitHub Release
The workflow automatically:
1. Builds the APK when you push a version tag
2. Signs it with your keystore (if configured)
3. Uploads to the GitHub release
Just push a tag:
```bash
git tag v0.5.0
git push origin v0.5.0
```
The APK will appear in the release as `serene-pub-0.5.0-android.apk`

64
android/app/build.gradle Normal file
View file

@ -0,0 +1,64 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'pub.serene.app'
compileSdk 34
defaultConfig {
applicationId "pub.serene.app"
minSdk 26
targetSdk 34
versionCode 1
versionName "0.5.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
release {
// GitHub Actions will set these from secrets
if (System.getenv("KEYSTORE_FILE")) {
storeFile file(System.getenv("KEYSTORE_FILE"))
storePassword System.getenv("KEYSTORE_PASSWORD")
keyAlias System.getenv("KEY_ALIAS")
keyPassword System.getenv("KEY_PASSWORD")
}
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
debug {
applicationIdSuffix ".debug"
versionNameSuffix "-debug"
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}

8
android/app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,8 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
-keep class pub.serene.app.** { *; }
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}

View file

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="pub.serene.app">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:label="Serene Pub"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:requestLegacyExternalStorage="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden"
android:theme="@style/AppTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Foreground service for Node.js process -->
<service
android:name=".NodeService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>

View file

@ -0,0 +1,188 @@
package pub.serene.app
import android.content.Intent
import android.os.Bundle
import android.webkit.WebView
import android.webkit.WebViewClient
import android.webkit.WebSettings
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import kotlinx.coroutines.*
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
class MainActivity : AppCompatActivity() {
private lateinit var webView: WebView
private var serverCheckJob: Job? = null
companion object {
const val SERVER_URL = "http://localhost:3000"
const val MAX_WAIT_TIME = 30000L // 30 seconds
const val CHECK_INTERVAL = 500L // 500ms
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Setup WebView
webView = WebView(this)
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
cacheMode = WebSettings.LOAD_DEFAULT
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
allowFileAccess = true
allowContentAccess = true
}
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
view.loadUrl(url)
return true
}
}
setContentView(webView)
// Extract assets on first run
val extractedMarker = File(filesDir, ".serene-extracted")
if (!extractedMarker.exists()) {
Toast.makeText(this, "Setting up Serene Pub...", Toast.LENGTH_LONG).show()
extractAssets()
extractedMarker.createNewFile()
}
// Start Node.js service
startNodeService()
// Wait for server to be ready
waitForServerAndLoad()
}
private fun extractAssets() {
try {
val assetManager = assets
val files = assetManager.list("serene-pub") ?: arrayOf()
for (filename in files) {
copyAssetFolder("serene-pub/$filename", filesDir.path)
}
} catch (e: Exception) {
Toast.makeText(this, "Error setting up: ${e.message}", Toast.LENGTH_LONG).show()
}
}
private fun copyAssetFolder(srcPath: String, destPath: String) {
try {
val assetManager = assets
val files = assetManager.list(srcPath)
if (files.isNullOrEmpty()) {
// It's a file, copy it
copyAssetFile(srcPath, destPath)
} else {
// It's a folder, create it and copy contents
val destFolder = File(destPath, srcPath.substringAfterLast("/"))
if (!destFolder.exists()) {
destFolder.mkdirs()
}
for (file in files) {
copyAssetFolder("$srcPath/$file", destFolder.path)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun copyAssetFile(srcPath: String, destPath: String) {
try {
val inputStream = assets.open(srcPath)
val fileName = srcPath.substringAfterLast("/")
val outFile = File(destPath, fileName)
outFile.parentFile?.mkdirs()
val outputStream = FileOutputStream(outFile)
inputStream.copyTo(outputStream)
inputStream.close()
outputStream.close()
// Make node binary executable
if (fileName == "node") {
outFile.setExecutable(true)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun startNodeService() {
val intent = Intent(this, NodeService::class.java)
intent.putExtra("dataDir", filesDir.path)
startForegroundService(intent)
}
private fun waitForServerAndLoad() {
var elapsedTime = 0L
serverCheckJob = CoroutineScope(Dispatchers.IO).launch {
while (elapsedTime < MAX_WAIT_TIME) {
if (isServerReady()) {
withContext(Dispatchers.Main) {
webView.loadUrl(SERVER_URL)
Toast.makeText(
this@MainActivity,
"Serene Pub ready!",
Toast.LENGTH_SHORT
).show()
}
return@launch
}
delay(CHECK_INTERVAL)
elapsedTime += CHECK_INTERVAL
}
// Timeout
withContext(Dispatchers.Main) {
Toast.makeText(
this@MainActivity,
"Server startup timeout. Please restart the app.",
Toast.LENGTH_LONG
).show()
}
}
}
private fun isServerReady(): Boolean {
return try {
val url = URL(SERVER_URL)
val connection = url.openConnection() as HttpURLConnection
connection.connectTimeout = 1000
connection.requestMethod = "GET"
connection.connect()
val responseCode = connection.responseCode
connection.disconnect()
responseCode in 200..399
} catch (e: Exception) {
false
}
}
override fun onBackPressed() {
if (webView.canGoBack()) {
webView.goBack()
} else {
super.onBackPressed()
}
}
override fun onDestroy() {
super.onDestroy()
serverCheckJob?.cancel()
stopService(Intent(this, NodeService::class.java))
}
}

View file

@ -0,0 +1,114 @@
package pub.serene.app
import android.app.*
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import java.io.File
class NodeService : Service() {
private var nodeProcess: Process? = null
companion object {
const val CHANNEL_ID = "serene_pub_service"
const val NOTIFICATION_ID = 1
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val dataDir = intent?.getStringExtra("dataDir") ?: filesDir.path
// Start foreground service
val notification = createNotification()
startForeground(NOTIFICATION_ID, notification)
// Start Node.js server
startNodeServer(dataDir)
return START_STICKY
}
private fun startNodeServer(dataDir: String) {
try {
val nodeBinary = File(dataDir, "node")
val appMain = File(dataDir, "build/index.js")
if (!nodeBinary.exists() || !appMain.exists()) {
throw Exception("Node binary or app not found")
}
val processBuilder = ProcessBuilder()
.command(nodeBinary.absolutePath, appMain.absolutePath)
.directory(File(dataDir))
.redirectErrorStream(true)
// Set environment variables
val env = processBuilder.environment()
env["NODE_ENV"] = "production"
env["PORT"] = "3000"
env["SERENE_PUB_DATA_DIR"] = dataDir
nodeProcess = processBuilder.start()
// Log output for debugging (optional)
Thread {
nodeProcess?.inputStream?.bufferedReader()?.useLines { lines ->
lines.forEach { line ->
android.util.Log.d("NodeJS", line)
}
}
}.start()
} catch (e: Exception) {
android.util.Log.e("NodeService", "Failed to start Node.js", e)
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Serene Pub Service",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Keeps Serene Pub running in the background"
}
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
}
private fun createNotification(): Notification {
val notificationIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this,
0,
notificationIntent,
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Serene Pub")
.setContentText("Server running")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
}
override fun onDestroy() {
super.onDestroy()
nodeProcess?.destroy()
nodeProcess = null
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
}

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="colorPrimary">#6200EE</item>
<item name="colorPrimaryDark">#3700B3</item>
<item name="colorAccent">#03DAC5</item>
<item name="windowNoTitle">true</item>
<item name="windowActionBar">false</item>
<item name="android:windowFullscreen">false</item>
</style>
</resources>

22
android/build.gradle Normal file
View file

@ -0,0 +1,22 @@
// Top-level build file
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.2.0'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.21'
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View file

@ -0,0 +1,5 @@
# Project-wide Gradle settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

18
android/settings.gradle Normal file
View file

@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Serene Pub"
include ':app'

View file

@ -35,7 +35,12 @@
"db:studio": "node scripts/check-db-lock.js drizzle-kit studio --config ./src/lib/server/db/drizzle.config.ts",
"bundle": "node scripts/bundle-dist.js",
"dist": "npm run build && npm run bundle",
"create-executables": "node scripts/create-executables.js"
"create-executables": "node scripts/create-executables.js",
"android:setup": "bash scripts/setup-gradle-wrapper.sh",
"android:prepare": "node scripts/build-android.js",
"android:build": "cd android && ./gradlew assembleRelease",
"android:build:debug": "cd android && ./gradlew assembleDebug",
"android:full": "npm run build && npm run android:prepare && npm run android:build"
},
"devDependencies": {
"@enviro/metadata": "^1.5.1",

125
scripts/build-android.js Normal file
View file

@ -0,0 +1,125 @@
#!/usr/bin/env node
/**
* Build script for Android APK
* Prepares Serene Pub bundle and packages it into Android assets
*/
import fs from "fs"
import path from "path"
import { execSync } from "child_process"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const rootDir = path.resolve(__dirname, "..")
const androidDir = path.join(rootDir, "android")
const assetsDir = path.join(androidDir, "app/src/main/assets/serene-pub")
const buildDir = path.join(rootDir, "build")
const nodeModulesDir = path.join(rootDir, "node_modules")
const staticDir = path.join(rootDir, "static")
const drizzleDir = path.join(rootDir, "drizzle")
console.log("🤖 Building Serene Pub for Android...")
// 1. Clean assets directory
if (fs.existsSync(assetsDir)) {
console.log("Cleaning assets directory...")
fs.rmSync(assetsDir, { recursive: true, force: true })
}
fs.mkdirSync(assetsDir, { recursive: true })
// 2. Copy build output
console.log("Copying build files...")
copyRecursive(buildDir, path.join(assetsDir, "build"))
// 3. Copy node_modules (production only)
console.log("Copying node_modules...")
copyRecursive(nodeModulesDir, path.join(assetsDir, "node_modules"))
// 4. Copy static assets
console.log("Copying static files...")
copyRecursive(staticDir, path.join(assetsDir, "static"))
// 5. Copy drizzle migrations
console.log("Copying database migrations...")
copyRecursive(drizzleDir, path.join(assetsDir, "drizzle"))
// 6. Download and copy Node.js binary for Android ARM64
console.log("Downloading Node.js binary for Android ARM64...")
const nodeVersion = "v20.13.1"
const nodeUrl = `https://nodejs.org/dist/${nodeVersion}/node-${nodeVersion}-linux-arm64.tar.xz`
const tempDir = path.join(rootDir, "temp-node-android")
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true })
}
try {
// Download Node.js
execSync(`curl -L -o ${tempDir}/node.tar.xz ${nodeUrl}`, { stdio: "inherit" })
// Extract
execSync(`tar -xf ${tempDir}/node.tar.xz -C ${tempDir}`, { stdio: "inherit" })
// Copy binary
const nodeBinaryPath = path.join(tempDir, `node-${nodeVersion}-linux-arm64/bin/node`)
fs.copyFileSync(nodeBinaryPath, path.join(assetsDir, "node"))
// Make executable
fs.chmodSync(path.join(assetsDir, "node"), 0o755)
console.log("✅ Node.js binary copied")
} catch (error) {
console.error("Error downloading Node.js:", error)
process.exit(1)
} finally {
// Cleanup
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true })
}
}
// 7. Create package.json for runtime
const runtimePackage = {
type: "module",
name: "serene-pub-android",
version: "0.5.0",
private: true
}
fs.writeFileSync(
path.join(assetsDir, "package.json"),
JSON.stringify(runtimePackage, null, 2)
)
console.log("\n✅ Assets prepared successfully!")
console.log("\nNext steps:")
console.log(" cd android")
console.log(" ./gradlew assembleRelease")
console.log("\nOr for debug:")
console.log(" ./gradlew assembleDebug")
// Helper function
function copyRecursive(src, dest) {
if (!fs.existsSync(src)) {
console.warn(`Warning: ${src} does not exist, skipping...`)
return
}
const stats = fs.statSync(src)
if (stats.isDirectory()) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true })
}
const entries = fs.readdirSync(src, { withFileTypes: true })
for (const entry of entries) {
copyRecursive(
path.join(src, entry.name),
path.join(dest, entry.name)
)
}
} else {
fs.copyFileSync(src, dest)
}
}

View file

@ -0,0 +1,36 @@
#!/bin/bash
# Download and setup Gradle wrapper for Android builds
GRADLE_VERSION="8.2"
GRADLE_ZIP="gradle-${GRADLE_VERSION}-bin.zip"
GRADLE_URL="https://services.gradle.org/distributions/${GRADLE_ZIP}"
cd "$(dirname "$0")/../android" || exit 1
echo "Downloading Gradle wrapper ${GRADLE_VERSION}..."
curl -L -o "${GRADLE_ZIP}" "${GRADLE_URL}"
echo "Extracting..."
unzip -q "${GRADLE_ZIP}"
mv "gradle-${GRADLE_VERSION}" gradle
echo "Creating wrapper files..."
cat > gradlew << 'EOF'
#!/bin/sh
GRADLE_HOME="$(cd "$(dirname "$0")/gradle" && pwd)"
exec "${GRADLE_HOME}/bin/gradle" "$@"
EOF
cat > gradlew.bat << 'EOF'
@echo off
set GRADLE_HOME=%~dp0gradle
"%GRADLE_HOME%\bin\gradle.bat" %*
EOF
chmod +x gradlew
echo "Cleaning up..."
rm "${GRADLE_ZIP}"
echo "✅ Gradle wrapper installed successfully!"
echo "Test with: cd android && ./gradlew --version"

View file

@ -49,6 +49,10 @@
isGuest: boolean
// Additional needed props
lastPersonaMessage: SelectChatMessage | undefined
// Summarization mode
isSummarizationMode?: boolean
isSelected?: boolean
onStartSummarization?: (msg: SelectChatMessage) => void
// Snippets
GeneratingAnimationComponent?: Snippet<[]>
messageControls?: Snippet<[SelectChatMessage]>
@ -84,6 +88,9 @@
hasGeneratingMessage,
isGuest,
lastPersonaMessage,
isSummarizationMode = false,
isSelected = false,
onStartSummarization,
GeneratingAnimationComponent,
messageControls,
generatingAnimation
@ -111,8 +118,12 @@
<li
id="message-{msg.id}"
class="preset-filled-primary-50-950 flex flex-col rounded-lg p-2"
class:opacity-50={msg.isHidden && editChatMessage?.id !== msg.id}
class="{isSummarizationMode
? isSelected
? 'preset-filled-secondary-100-900'
: 'preset-tonal-surface opacity-60'
: 'preset-filled-primary-50-950'} flex flex-col rounded-lg p-2 transition-colors duration-150"
class:opacity-50={!isSummarizationMode && msg.isHidden && editChatMessage?.id !== msg.id}
tabindex="-1"
role="article"
aria-label="Message {index + 1} of {chat.chatMessages.length} from {(
@ -196,6 +207,7 @@
{onContinueMessage}
{onAbortMessage}
{onBranchMessage}
{onStartSummarization}
/>
{/if}
</div>
@ -242,6 +254,7 @@
{onContinueMessage}
{onAbortMessage}
{onBranchMessage}
{onStartSummarization}
/>
{/if}
</article>

View file

@ -15,6 +15,7 @@
onAbortMessage: (e: Event, msg: SelectChatMessage) => void
onBranchMessage?: (e: Event, msg: SelectChatMessage) => void
onContinueMessage?: (e: Event, msg: SelectChatMessage) => void
onStartSummarization?: (msg: SelectChatMessage) => void
}
let {
@ -29,7 +30,8 @@
onRegenerateMessage,
onAbortMessage,
onBranchMessage,
onContinueMessage
onContinueMessage,
onStartSummarization
}: Props = $props()
</script>
@ -116,6 +118,18 @@
<span class="lg:hidden">Stop Generation</span>
</button>
{/if}
{#if onStartSummarization && !msg.isGenerating}
<button
class="btn btn-sm msg-cntrl-icon hover:preset-filled-warning-500"
title="Select for Summarization"
aria-label="Select this message for summarization"
disabled={!!editChatMessage || hasGeneratingMessage}
onclick={() => onStartSummarization!(msg)}
>
<Icons.BookMarked size={16} aria-hidden="true" />
<span class="lg:hidden">Select for Summarization</span>
</button>
{/if}
</div>
<style lang="postcss">

View file

@ -0,0 +1,649 @@
<script lang="ts">
import { Modal } from "@skeletonlabs/skeleton-svelte"
import * as Icons from "@lucide/svelte"
import * as skio from "sveltekit-io"
import { onDestroy, onMount } from "svelte"
import { toaster } from "$lib/client/utils/toaster"
function computeDefaultDate(
entries: Sockets.HistoryEntries.List.Response["historyEntryList"]
): { year: number; month: number; day: number } {
const dated = entries.filter((e) => e.year !== null)
if (dated.length === 0) return { year: 1, month: 1, day: 1 }
// Sort descending by year → month → day to find the latest entry
const latest = dated.sort((a, b) => {
if ((a.year ?? 0) !== (b.year ?? 0)) return (b.year ?? 0) - (a.year ?? 0)
if ((a.month ?? 0) !== (b.month ?? 0)) return (b.month ?? 0) - (a.month ?? 0)
return (b.day ?? 0) - (a.day ?? 0)
})[0]
return {
year: latest.year ?? 1,
month: latest.month ?? 1,
day: (latest.day ?? 0) + 1
}
}
interface Props {
open: boolean
onOpenChange: (e: { open: boolean }) => void
chatId: number
lorebookId: number | null
selectedMessageIds: number[]
initialLoreType?: "world" | "history"
onSaved: () => void
onLorebookSet: (lorebookId: number) => void
}
let {
open = $bindable(),
onOpenChange,
chatId,
lorebookId = $bindable(),
selectedMessageIds,
initialLoreType = "world",
onSaved,
onLorebookSet
}: Props = $props()
const socket = skio.get()!
// ── Step management ──────────────────────────────────────────
type Step = "configure" | "generating" | "review"
let step = $state<Step>("configure")
// ── Configure step state ──────────────────────────────────────
let loreType = $state<"world" | "history">(initialLoreType)
let topic = $state("")
// Lorebook attachment
let availableLorebooks = $state<Sockets.Lorebooks.List.Response["lorebookList"]>([])
let attachingLorebookId = $state<number | "">("")
let isCreatingLorebook = $state(false)
let newLorebookName = $state("")
let historyEntryList = $state<Sockets.HistoryEntries.List.Response["historyEntryList"]>([])
// ── Generating step state ─────────────────────────────────────
let currentBatch = $state(0)
let totalBatches = $state(1)
let partialSummary = $state<{ content?: string; raw?: string }>({})
// ── Review step state ─────────────────────────────────────────
let reviewName = $state("")
let reviewContent = $state("")
let reviewYear = $state<number | null>(null)
let reviewMonth = $state<number | null>(null)
let reviewDay = $state<number | null>(null)
let rawOutput = $state("")
let showRaw = $state(false)
let isSaving = $state(false)
// ── Derived ───────────────────────────────────────────────────
let progressPercent = $derived(
totalBatches > 0 ? Math.round((currentBatch / totalBatches) * 100) : 0
)
let canGenerate = $derived(
!!lorebookId && selectedMessageIds.length > 0
)
let canSave = $derived(
loreType === "history"
? reviewContent.trim().length > 0
: reviewName.trim().length > 0 && reviewContent.trim().length > 0
)
let hasLorebook = $derived(!!lorebookId)
// ── Reset on open ─────────────────────────────────────────────
$effect(() => {
if (open) {
step = "configure"
loreType = initialLoreType
topic = ""
attachingLorebookId = ""
isCreatingLorebook = false
newLorebookName = ""
historyEntryList = []
summarizePhase = "drafting"
currentBatch = 0
totalBatches = 1
partialSummary = {}
rawOutput = ""
showRaw = false
reviewName = ""
reviewContent = ""
reviewYear = null
reviewMonth = null
reviewDay = null
}
})
// Fetch history entries whenever the lorebook is set while the modal is open
$effect(() => {
if (open && lorebookId) {
socket.emit("historyEntries:list", { lorebookId })
}
})
// ── Socket handlers ───────────────────────────────────────────
let summarizePhase = $state<"drafting" | "synthesizing">("drafting")
function handleProgress(data: Sockets.Chats.Summarize.Progress) {
summarizePhase = data.phase
currentBatch = data.batch
totalBatches = data.totalBatches
partialSummary = data.partial
}
function handleComplete(data: Sockets.Chats.Summarize.Response) {
rawOutput = data.raw
reviewName = data.name ?? ""
reviewContent = data.content ?? data.raw ?? ""
if (loreType === "history") {
const defaultDate = computeDefaultDate(historyEntryList)
reviewYear = defaultDate.year
reviewMonth = defaultDate.month
reviewDay = defaultDate.day
}
step = "review"
}
function handleError(data: Sockets.Chats.Summarize.ErrorResponse) {
if (data.reason === "no_lorebook") {
toaster.error({ title: "No lorebook attached", description: data.error })
step = "configure"
} else {
toaster.error({ title: "Summarization failed", description: data.error })
step = "configure"
}
}
function handleLorebooksList(data: Sockets.Lorebooks.List.Response) {
availableLorebooks = data.lorebookList
}
function handleSetLorebook(data: Sockets.Chats.SetLorebook.Response) {
lorebookId = data.chat.lorebookId
if (data.chat.lorebookId) {
onLorebookSet(data.chat.lorebookId)
toaster.success({ title: "Lorebook attached" })
}
}
function handleLorebookCreate(data: any) {
if (data.lorebook) {
availableLorebooks = [...availableLorebooks, data.lorebook]
attachLorebookToChat(data.lorebook.id)
}
}
function handleHistoryEntriesList(data: Sockets.HistoryEntries.List.Response) {
historyEntryList = data.historyEntryList
}
onMount(() => {
socket.on("chats:summarize:progress", handleProgress)
socket.on("chats:summarize:complete", handleComplete)
socket.on("chats:summarize:error", handleError)
socket.on("lorebooks:list", handleLorebooksList)
socket.on("chats:setLorebook", handleSetLorebook)
socket.on("lorebooks:create", handleLorebookCreate)
socket.on("historyEntries:list", handleHistoryEntriesList)
socket.emit("lorebooks:list", {})
})
onDestroy(() => {
socket.off("chats:summarize:progress")
socket.off("chats:summarize:complete")
socket.off("chats:summarize:error")
socket.off("lorebooks:list")
socket.off("chats:setLorebook")
socket.off("lorebooks:create")
socket.off("historyEntries:list")
})
// ── Actions ───────────────────────────────────────────────────
function attachLorebookToChat(id: number) {
socket.emit("chats:setLorebook", { chatId, lorebookId: id })
attachingLorebookId = ""
}
function confirmAttachExisting() {
if (!attachingLorebookId) return
attachLorebookToChat(Number(attachingLorebookId))
}
function createAndAttachLorebook() {
if (!newLorebookName.trim()) return
socket.emit("lorebooks:create", {
lorebook: { name: newLorebookName.trim() }
})
newLorebookName = ""
isCreatingLorebook = false
}
function generate() {
step = "generating"
currentBatch = 0
totalBatches = 1
partialSummary = {}
socket.emit("chats:summarize", {
chatId,
messageIds: selectedMessageIds,
loreType,
topic: topic.trim() || undefined
} satisfies Sockets.Chats.Summarize.Params)
}
function saveEntry() {
if (!canSave || !lorebookId) return
isSaving = true
if (loreType === "world") {
socket.emit("worldLoreEntries:create", {
worldLoreEntry: {
lorebookId,
name: reviewName.trim(),
content: reviewContent.trim(),
keys: "",
enabled: true,
constant: false,
useRegex: false,
caseSensitive: false,
priority: 1
}
})
} else {
socket.emit("historyEntries:create", {
historyEntry: {
lorebookId,
year: reviewYear ?? 1,
month: reviewMonth || null,
day: reviewDay || null,
content: reviewContent.trim(),
keys: "",
enabled: true,
constant: false,
useRegex: false,
caseSensitive: false
}
})
}
toaster.success({
title: loreType === "world" ? "World lore entry saved" : "History entry saved"
})
isSaving = false
onSaved()
onOpenChange({ open: false })
}
function goBack() {
step = "configure"
}
</script>
<Modal
{open}
{onOpenChange}
contentBase="card bg-surface-100-900 p-6 shadow-xl w-[min(95vw,560px)] max-h-[90vh] overflow-y-auto"
backdropClasses="backdrop-blur-sm"
>
{#snippet content()}
<!-- ── STEP 1: Configure ─────────────────────────────── -->
{#if step === "configure"}
<header class="mb-4 flex items-center justify-between">
<h2 id="summarize-modal-title" class="h3">
Summarize to Lorebook
</h2>
<Icons.BookOpen size={24} class="text-primary-500" />
</header>
<div class="space-y-5">
<!-- Lorebook status -->
<div class="rounded-lg border border-surface-300-700 p-3">
{#if hasLorebook}
{@const book = availableLorebooks.find(l => l.id === lorebookId)}
<div class="flex items-center gap-2 text-sm">
<Icons.BookMarked size={16} class="text-success-500 shrink-0" />
<span class="text-surface-600-400">Saving to:</span>
<span class="font-semibold">{book?.name ?? `Lorebook #${lorebookId}`}</span>
</div>
{:else}
<div class="space-y-3">
<div class="flex items-start gap-2 text-sm">
<Icons.TriangleAlert size={16} class="text-warning-500 mt-0.5 shrink-0" />
<span>No lorebook is attached to this chat. Attach one to continue.</span>
</div>
{#if !isCreatingLorebook}
<div class="flex flex-wrap gap-2">
<select
class="select flex-1 text-sm"
bind:value={attachingLorebookId}
>
<option value="">Select existing lorebook…</option>
{#each availableLorebooks as lb}
<option value={lb.id}>{lb.name}</option>
{/each}
</select>
<button
class="btn btn-sm preset-filled-primary-500"
disabled={!attachingLorebookId}
onclick={confirmAttachExisting}
>
Attach
</button>
<button
class="btn btn-sm preset-tonal-surface"
onclick={() => (isCreatingLorebook = true)}
>
<Icons.Plus size={14} />
New
</button>
</div>
{:else}
<div class="flex gap-2">
<input
class="input flex-1 text-sm"
type="text"
placeholder="New lorebook name…"
bind:value={newLorebookName}
onkeydown={(e) => e.key === "Enter" && createAndAttachLorebook()}
/>
<button
class="btn btn-sm preset-filled-primary-500"
disabled={!newLorebookName.trim()}
onclick={createAndAttachLorebook}
>
Create & Attach
</button>
<button
class="btn btn-sm preset-tonal-surface"
onclick={() => (isCreatingLorebook = false)}
>
<Icons.X size={14} />
</button>
</div>
{/if}
</div>
{/if}
</div>
<!-- Entry type -->
<fieldset class="space-y-2">
<legend class="label text-sm font-semibold">Entry type</legend>
<div class="flex gap-4">
<label class="flex cursor-pointer items-center gap-2">
<input
type="radio"
class="radio"
name="loreType"
value="world"
bind:group={loreType}
/>
<Icons.Globe size={16} />
<span class="text-sm">World Lore</span>
</label>
<label class="flex cursor-pointer items-center gap-2">
<input
type="radio"
class="radio"
name="loreType"
value="history"
bind:group={loreType}
/>
<Icons.Scroll size={16} />
<span class="text-sm">History Entry</span>
</label>
</div>
</fieldset>
<!-- Topic (world lore only) -->
{#if loreType === "world"}
<div class="space-y-1">
<label class="label text-sm font-semibold" for="summarize-topic">
Focus topic
<span class="text-surface-400 font-normal">(optional)</span>
</label>
<input
id="summarize-topic"
class="input text-sm"
type="text"
placeholder='e.g. "the guards in the Labyrinth of Descia"'
bind:value={topic}
/>
{#if topic.trim()}
<p class="text-surface-500 text-xs">
Prompt will include: <em>"Specifically focus on: "{topic.trim()}""</em>
</p>
{/if}
</div>
{/if}
<!-- Message count -->
<p class="text-surface-500 text-sm">
<Icons.MessageSquare size={14} class="mr-1 inline" />
Summarizing
<strong>{selectedMessageIds.length}</strong>
{selectedMessageIds.length === 1 ? "message" : "messages"}
</p>
</div>
<footer class="mt-6 flex justify-end gap-3">
<button
class="btn preset-filled-surface-500"
onclick={() => onOpenChange({ open: false })}
>
Cancel
</button>
<button
class="btn preset-filled-primary-500"
disabled={!canGenerate}
onclick={generate}
title={!hasLorebook ? "Attach a lorebook first" : !selectedMessageIds.length ? "Select at least one message" : ""}
>
<Icons.Sparkles size={16} />
Generate Summary
</button>
</footer>
<!-- ── STEP 2: Generating ────────────────────────────── -->
{:else if step === "generating"}
<header class="mb-4">
<h2 id="summarize-modal-title" class="h3">Generating Summary…</h2>
</header>
<div class="space-y-4">
<!-- Progress -->
<div class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-surface-500">
{#if summarizePhase === "synthesizing"}
Synthesizing final entry…
{:else if currentBatch > 0}
Drafting part {currentBatch} of {totalBatches}
{:else}
Starting…
{/if}
</span>
<span class="font-mono text-sm">{progressPercent}%</span>
</div>
<div class="bg-surface-300-700 h-2 w-full overflow-hidden rounded-full">
<div
class="bg-primary-500 h-full rounded-full transition-all duration-300"
style="width: {progressPercent}%"
></div>
</div>
</div>
<!-- Live draft preview -->
{#if partialSummary.content || partialSummary.raw}
<div class="space-y-1">
<p class="text-surface-500 text-xs font-semibold uppercase tracking-wide">
{summarizePhase === "synthesizing" ? "Final entry" : `Draft ${currentBatch}`}
</p>
<div class="bg-surface-200-800 rounded-lg p-3 text-sm">
{#if partialSummary.content}
<p class="text-surface-700-300 line-clamp-6 whitespace-pre-wrap">
{partialSummary.content}
</p>
{:else if partialSummary.raw}
<p class="text-surface-500 line-clamp-6 whitespace-pre-wrap text-xs italic">
{partialSummary.raw}
</p>
{/if}
</div>
</div>
{:else if summarizePhase === "synthesizing"}
<div class="text-surface-500 py-4 text-center text-sm">
<div class="bg-primary-500 mx-auto mb-2 h-2 w-2 animate-pulse rounded-full"></div>
Synthesizing final entry…
</div>
{:else}
<div class="text-surface-500 py-4 text-center text-sm">
<div class="bg-primary-500 mx-auto mb-2 h-2 w-2 animate-pulse rounded-full"></div>
Waiting for first draft…
</div>
{/if}
</div>
<footer class="mt-6 flex justify-end">
<button
class="btn preset-filled-error-500"
onclick={() => {
step = "configure"
}}
>
<Icons.X size={16} />
Cancel
</button>
</footer>
<!-- ── STEP 3: Review & Edit ─────────────────────────── -->
{:else if step === "review"}
<header class="mb-4 flex items-center justify-between">
<h2 id="summarize-modal-title" class="h3">Review & Save</h2>
<span class="badge preset-tonal-primary text-xs capitalize">
{loreType === "world" ? "World Lore" : "History Entry"}
</span>
</header>
<div class="space-y-4">
<!-- Name (world lore only — history entries are identified by date) -->
{#if loreType === "world"}
<div class="space-y-1">
<label class="label text-sm font-semibold" for="review-name">
Name <span class="text-error-500">*</span>
</label>
<input
id="review-name"
class="input text-sm"
type="text"
placeholder="Entry name…"
bind:value={reviewName}
/>
{#if reviewName}
<p class="text-surface-500 text-xs">Auto-generated — edit if needed.</p>
{/if}
</div>
{/if}
<!-- Date (history only) -->
{#if loreType === "history"}
<div class="space-y-1">
<p class="label text-sm font-semibold">
In-world date
<span class="text-surface-400 font-normal">(optional)</span>
</p>
<div class="flex gap-2">
<label class="flex flex-1 flex-col gap-1">
<span class="text-surface-500 text-xs">Year</span>
<input
class="input text-sm"
type="number"
placeholder="e.g. 412"
bind:value={reviewYear}
/>
</label>
<label class="flex flex-1 flex-col gap-1">
<span class="text-surface-500 text-xs">Month</span>
<input
class="input text-sm"
type="number"
min="1"
max="12"
placeholder="112"
bind:value={reviewMonth}
/>
</label>
<label class="flex flex-1 flex-col gap-1">
<span class="text-surface-500 text-xs">Day</span>
<input
class="input text-sm"
type="number"
min="1"
max="31"
placeholder="131"
bind:value={reviewDay}
/>
</label>
</div>
</div>
{/if}
<!-- Content -->
<div class="space-y-1">
<label class="label text-sm font-semibold" for="review-content">
Content <span class="text-error-500">*</span>
</label>
<textarea
id="review-content"
class="textarea min-h-32 text-sm"
placeholder="Entry content…"
bind:value={reviewContent}
></textarea>
</div>
<!-- Raw output toggle -->
<div>
<button
class="text-surface-500 hover:text-surface-700-300 flex items-center gap-1 text-xs"
onclick={() => (showRaw = !showRaw)}
>
<Icons.ChevronDown
size={14}
class="transition-transform {showRaw ? 'rotate-180' : ''}"
/>
{showRaw ? "Hide" : "Show"} raw LLM output
</button>
{#if showRaw}
<pre class="bg-surface-200-800 mt-2 overflow-x-auto rounded p-3 text-xs whitespace-pre-wrap">{rawOutput}</pre>
{/if}
</div>
</div>
<footer class="mt-6 flex flex-wrap gap-3">
<button class="btn preset-tonal-surface" onclick={goBack}>
<Icons.ChevronLeft size={16} />
Back
</button>
<button class="btn preset-tonal-surface" onclick={generate}>
<Icons.RefreshCw size={16} />
Re-generate
</button>
<div class="ml-auto">
<button
class="btn preset-filled-primary-500"
disabled={!canSave || isSaving}
onclick={saveEntry}
>
<Icons.Save size={16} />
Save to Lorebook
</button>
</div>
</footer>
{/if}
{/snippet}
</Modal>

View file

@ -14,15 +14,23 @@
let { onclose = $bindable() }: Props = $props()
let userCtx: { user: SelectUser } = getContext("userCtx")
let userSettingsCtx: UserSettingsCtx = getContext("userSettingsCtx")
let userCtx: UserCtx = getContext("userCtx")
const socket = useTypedSocket()
let activeSamplingConfigId = $derived(
userSettingsCtx.settings?.activeSamplingConfigId ?? null
userCtx.user?.activeSamplingConfig?.id ?? null
)
// Sync local editable copy whenever the active config changes in the global context
$effect(() => {
const config = userCtx.user?.activeSamplingConfig
if (config) {
sampling = { ...config }
originalSamplingConfig = { ...config }
}
})
let sampling: SelectSamplingConfig | undefined = $state()
let originalSamplingConfig: SelectSamplingConfig | undefined = $state()
let unsavedChanges = $derived.by(() => {
@ -158,7 +166,7 @@
delete newSamplingConfig.id
delete newSamplingConfig.isImmutable
newSamplingConfig.name = name.trim()
socket.emit("samplingConfigs:create", { sampling: newSamplingConfig })
socket.emit("samplingConfigs:create", { sampling: { ...newSamplingConfig, name: name.trim() } })
showNewNameModal = false
}
function handleNewNameCancel() {
@ -220,7 +228,7 @@
function confirmDelete() {
socket.emit("samplingConfigs:delete", {
id: activeSamplingConfigId
id: activeSamplingConfigId!
})
showDeleteModal = false
}
@ -264,50 +272,35 @@
onMount(() => {
onclose = handleOnClose
socket.on("samplingConfigs:get", (message: Sockets.SamplingConfigs.Get.Response) => {
sampling = { ...message.sampling }
originalSamplingConfig = { ...message.sampling }
})
socket.on(
"samplingConfigs:list",
(message: Sockets.SamplingConfigs.List.Response) => {
samplingConfigsList = message.samplingConfigsList
if (
!activeSamplingConfigId &&
samplingConfigsList.length > 0
) {
socket.emit("samplingConfigs:setUserActive", {
id: samplingConfigsList[0].id
})
}
}
)
socket.on(
"samplingConfigs:delete",
(message: Sockets.SamplingConfigs.Delete.Response) => {
(_message: Sockets.SamplingConfigs.Delete.Response) => {
toaster.success({ title: "Sampling Config Deleted" })
}
)
socket.on(
"samplingConfigs:update",
(message: Sockets.SamplingConfigs.Update.Response) => {
(_message: Sockets.SamplingConfigs.Update.Response) => {
toaster.success({ title: "Sampling Config Updated" })
}
)
socket.on(
"samplingConfigs:create",
(message: Sockets.SamplingConfigs.Create.Response) => {
(_message: Sockets.SamplingConfigs.Create.Response) => {
toaster.success({ title: "Sampling Config Created" })
}
)
socket.emit("samplingConfigs:get", { id: activeSamplingConfigId })
socket.emit("samplingConfigs:list", {})
})
onDestroy(() => {
socket.off("samplingConfigs:get")
socket.off("samplingConfigs:list")
socket.off("samplingConfigs:delete")
socket.off("samplingConfigs:update")
@ -390,16 +383,15 @@
<select
class="select select-sm bg-background border-muted rounded border"
onchange={handleSelectChange}
value={activeSamplingConfigId}
disabled={unsavedChanges}
>
{#each samplingConfigsList.filter((w) => w.isImmutable) as w}
<option value={w.id}>
<option value={w.id} selected={w.id === activeSamplingConfigId}>
{w.name}{#if w.isImmutable}*{/if}
</option>
{/each}
{#each samplingConfigsList.filter((w) => !w.isImmutable) as w}
<option value={w.id}>
<option value={w.id} selected={w.id === activeSamplingConfigId}>
{w.name}{#if w.isImmutable}*{/if}
</option>
{/each}

View file

@ -54,6 +54,7 @@ export abstract class BaseConnectionAdapter {
currentCharacterId: number | null
isAborting = false
isAssistantMode = false
isSummarizerMode = false
generatingMessageMetadata: any = {}
promptBuilder: PromptBuilder
@ -78,6 +79,7 @@ export abstract class BaseConnectionAdapter {
this.currentCharacterId = currentCharacterId
this.isAssistantMode =
isAssistantMode || chat.chatType === ChatTypes.ASSISTANT
this.isSummarizerMode = chat.chatType === ChatTypes.SUMMARIZE
this.generatingMessageMetadata = generatingMessageMetadata
this.promptBuilder = new PromptBuilder({
connection: this.connection,
@ -96,6 +98,10 @@ export abstract class BaseConnectionAdapter {
async compilePrompt(args: {}): Promise<PromptBuilderCompiledPrompt> {
this.promptBuilder.tokenLimit = await this.getContextTokenLimit()
if (this.isSummarizerMode) {
return await this.compileSummarizerPrompt()
}
// Use assistant prompt compilation for assistant mode
if (this.isAssistantMode) {
return await this.compileAssistantPrompt(args)
@ -247,6 +253,64 @@ export abstract class BaseConnectionAdapter {
return "function-calling"
}
/**
* Compile summarizer prompt passes promptConfig.systemPrompt directly to the LLM
* with no roleplay or assistant framing. Used for lore summarization.
*/
protected async compileSummarizerPrompt(): Promise<PromptBuilderCompiledPrompt> {
const messages: any[] = [
{
role: "system",
content: this.promptConfig.systemPrompt
}
]
for (const msg of this.chat.chatMessages) {
if (msg.isHidden) continue
messages.push({
role: msg.role === "assistant" ? "assistant" : "user",
content: msg.content
})
}
const totalTokens = await this.promptBuilder.tokenCounter.countTokens(
JSON.stringify(messages)
)
return {
prompt: undefined,
messages,
meta: {
promptFormat: "chat",
templateName: "summarizer",
timestamp: new Date().toISOString(),
truncationReason: null,
currentTurnCharacterId: null,
tokenCounts: {
total: totalTokens,
limit: await this.getContextTokenLimit()
},
chatMessages: {
included: this.chat.chatMessages.filter(
(m: SelectChatMessage) => !m.isHidden
).length,
total: this.chat.chatMessages.length,
includedIds: this.chat.chatMessages
.filter((m: SelectChatMessage) => !m.isHidden)
.map((m: SelectChatMessage) => m.id),
excludedIds: this.chat.chatMessages
.filter((m: SelectChatMessage) => m.isHidden)
.map((m: SelectChatMessage) => m.id)
},
sources: {
characters: [],
personas: [],
scenario: null
}
}
}
}
/**
* Compile assistant mode prompt (simple message history)
* Can be overridden by subclasses for custom formatting

View file

@ -26,9 +26,12 @@ import { registerChatHandlers } from "./chats"
import { registerPromptConfigHandlers } from "./promptConfigs"
import { registerUserHandlers } from "./users"
import { registerLorebookHandlers } from "./lorebooks"
import { registerWorldLoreEntryHandlers } from "./worldLoreEntries"
import { registerHistoryEntryHandlers } from "./historyEntries"
import { registerTagHandlers } from "./tags"
import { registerSystemSettingsHandlers } from "./systemSettings"
import { registerOllamaHandlers } from "./ollama"
import { registerSummarizeHandlers } from "./summarize"
const userId = 1 // Replace with actual user id
@ -58,7 +61,10 @@ export function connectSockets(io: {
registerPromptConfigHandlers(socket, emitToUser, register)
registerChatHandlers(socket, emitToUser, register)
registerLorebookHandlers(socket, emitToUser, register)
registerWorldLoreEntryHandlers(socket, emitToUser, register)
registerHistoryEntryHandlers(socket, emitToUser, register)
registerTagHandlers(socket, emitToUser, register)
registerSummarizeHandlers(socket, emitToUser, register)
console.log(`Socket connected: ${socket.id} for user ${userId}`)
})
}

View file

@ -0,0 +1,167 @@
import { db } from "$lib/server/db"
import * as schema from "$lib/server/db/schema"
import { and, eq, inArray } from "drizzle-orm"
import type { Handler } from "$lib/shared/events"
import { generateSummary } from "$lib/server/utils/summarizer"
import { getUserConfigurations } from "$lib/server/utils/getUserConfigurations"
export const chatsSummarizeHandler: Handler<
Sockets.Chats.Summarize.Params,
Sockets.Chats.Summarize.Response
> = {
event: "chats:summarize",
handler: async (socket, params, emitToUser) => {
const userId = socket.user!.id
const { chatId, messageIds, loreType, topic } = params
// Verify the user owns the chat
const chat = await db.query.chats.findFirst({
where: (c, { and, eq }) =>
and(eq(c.id, chatId), eq(c.userId, userId))
})
if (!chat) {
throw new Error("Chat not found or access denied.")
}
// Guard: chat must have a lorebook attached
if (!chat.lorebookId) {
emitToUser("chats:summarize:error", {
reason: "no_lorebook",
error: "This chat has no lorebook attached. Please attach or create one first."
})
return null as any
}
// Fetch the specified messages in order
const whereClause =
messageIds === "all"
? and(
eq(schema.chatMessages.chatId, chatId),
eq(schema.chatMessages.isHidden, false)
)
: and(
eq(schema.chatMessages.chatId, chatId),
inArray(schema.chatMessages.id, messageIds)
)
const rawMessages = await db.query.chatMessages.findMany({
where: whereClause,
orderBy: (cm, { asc }) => asc(cm.id)
})
if (rawMessages.length === 0) {
throw new Error("No messages found to summarize.")
}
// Collect unique character and persona IDs from messages
const charIds = [...new Set(rawMessages.filter((m) => m.characterId).map((m) => m.characterId!))]
const personaIds = [...new Set(rawMessages.filter((m) => m.personaId).map((m) => m.personaId!))]
// Fetch names directly from the entity tables
const characters = charIds.length > 0
? await db.query.characters.findMany({ where: inArray(schema.characters.id, charIds) })
: []
const personas = personaIds.length > 0
? await db.query.personas.findMany({ where: inArray(schema.personas.id, personaIds) })
: []
const characterMap = new Map(characters.map((c) => [c.id, c.name]))
const personaMap = new Map(personas.map((p) => [p.id, (p as any).nickname ?? p.name]))
const messages = rawMessages.map((msg) => {
let senderName = "Unknown"
if (msg.characterId && characterMap.has(msg.characterId)) {
senderName = characterMap.get(msg.characterId)!
} else if (msg.personaId && personaMap.has(msg.personaId)) {
senderName = personaMap.get(msg.personaId)!
} else if (msg.role === "user") {
senderName = "User"
}
return { senderName, content: msg.content }
})
// Get the user's active connection + configs
const { connection, sampling, contextConfig, promptConfig } =
await getUserConfigurations(userId)
// Run iterative summarization, streaming progress back to client
const result = await generateSummary({
messages,
loreType,
topic,
connection,
sampling,
contextConfig,
promptConfig,
onProgress: (data) => {
emitToUser("chats:summarize:progress", data satisfies Sockets.Chats.Summarize.Progress)
}
})
const response: Sockets.Chats.Summarize.Response = {
content: result.content ?? result.raw,
name: result.name,
raw: result.raw,
lorebookId: chat.lorebookId!,
batchCount: result.batchCount
}
emitToUser("chats:summarize:complete", response)
return response
}
}
export const chatsSetLorebookHandler: Handler<
Sockets.Chats.SetLorebook.Params,
Sockets.Chats.SetLorebook.Response
> = {
event: "chats:setLorebook",
handler: async (socket, params, emitToUser) => {
const userId = socket.user!.id
const { chatId, lorebookId } = params
// Verify ownership
const chat = await db.query.chats.findFirst({
where: (c, { and, eq }) => and(eq(c.id, chatId), eq(c.userId, userId))
})
if (!chat) {
throw new Error("Chat not found or access denied.")
}
// If attaching a lorebook, verify the user owns it
if (lorebookId !== null) {
const lorebook = await db.query.lorebooks.findFirst({
where: (l, { and, eq }) =>
and(eq(l.id, lorebookId), eq(l.userId, userId))
})
if (!lorebook) {
throw new Error("Lorebook not found or access denied.")
}
}
const [updated] = await db
.update(schema.chats)
.set({ lorebookId })
.where(eq(schema.chats.id, chatId))
.returning()
const response: Sockets.Chats.SetLorebook.Response = { chat: updated }
emitToUser("chats:setLorebook", response)
return response
}
}
export function registerSummarizeHandlers(
socket: any,
emitToUser: (event: string, data: any) => void,
register: (
socket: any,
handler: Handler<any, any>,
emitToUser: (event: string, data: any) => void
) => void
) {
register(socket, chatsSummarizeHandler, emitToUser)
register(socket, chatsSetLorebookHandler, emitToUser)
}

View file

@ -0,0 +1,251 @@
/**
* Summarizer two-phase lore entry generation.
*
* Phase 1 Batch drafting:
* Messages are split into token-sized batches. Each batch is drafted
* independently the LLM only sees that batch's messages (as JSON)
* and produces a single <content> draft. Drafts are collected in order.
*
* Phase 2 Synthesis:
* All drafts are passed as an ordered JSON array to a synthesis prompt.
* The LLM merges them into one coherent, past-tense narrative and
* produces a final <content>.
*/
import { getConnectionAdapter } from "../getConnectionAdapter"
import { TokenCounters } from "../TokenCounterManager"
import { ChatTypes } from "$lib/shared/constants/ChatTypes"
import {
buildBatchPrompt,
buildNamePrompt,
buildSynthesisPrompt,
formatMessagesAsJson,
type JsonDraft
} from "./templates"
import { parseSummaryOutput } from "./parser"
export type SummarizePhase = "drafting" | "synthesizing"
export interface SummarizeProgressData {
phase: SummarizePhase
batch: number
totalBatches: number
partial: { content?: string; raw?: string }
}
export interface SummarizeInput {
messages: { senderName: string; content: string }[]
loreType: "world" | "history"
topic?: string
connection: SelectConnection
sampling: SelectSamplingConfig
contextConfig: SelectContextConfig
promptConfig: SelectPromptConfig
onProgress?: (data: SummarizeProgressData) => void
}
export interface SummarizeResult {
content: string | undefined
name: string | undefined
raw: string
batchCount: number
}
function estimateTokens(text: string): number {
return Math.ceil(text.length / 3.5)
}
function batchMessages(
messages: { senderName: string; content: string }[],
tokenLimit: number
): { senderName: string; content: string }[][] {
// Reserve headroom for prompt template + draft output
const budget = Math.max(tokenLimit - 1500, 500)
const batches: { senderName: string; content: string }[][] = []
let current: { senderName: string; content: string }[] = []
let currentTokens = 0
for (const msg of messages) {
const msgTokens = estimateTokens(JSON.stringify({ speaker: msg.senderName, text: msg.content })) + 5
if (current.length > 0 && currentTokens + msgTokens > budget) {
batches.push(current)
current = [msg]
currentTokens = msgTokens
} else {
current.push(msg)
currentTokens += msgTokens
}
}
if (current.length > 0) batches.push(current)
return batches.length > 0 ? batches : [[]]
}
function buildMinimalChat(userPrompt: string): any {
return {
id: 0,
userId: 0,
name: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scenario: null,
metadata: null,
lorebookId: null,
isGroup: false,
chatType: ChatTypes.SUMMARIZE,
groupReplyStrategy: null,
chatMessages: [
{
id: 1,
chatId: 0,
role: "user",
content: userPrompt,
createdAt: new Date().toISOString(),
isHidden: false,
isGenerating: false,
metadata: null
}
],
lorebook: {
id: 0,
userId: 0,
name: "",
description: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
lorebookBindings: []
}
}
}
async function runGeneration(
promptData: { systemPrompt: string; userPrompt: string },
opts: {
connection: SelectConnection
sampling: SelectSamplingConfig
contextConfig: SelectContextConfig
promptConfig: SelectPromptConfig
tokenCounter: TokenCounters
tokenLimit: number
maxTokens: number
}
): Promise<string> {
const AdapterClass = getConnectionAdapter(opts.connection.type)
const fakeChat = buildMinimalChat(promptData.userPrompt)
const adapter = new AdapterClass.Adapter({
connection: opts.connection,
sampling: { ...opts.sampling, maxTokens: opts.maxTokens },
contextConfig: opts.contextConfig,
promptConfig: { ...opts.promptConfig, systemPrompt: promptData.systemPrompt },
chat: fakeChat,
currentCharacterId: null,
tokenCounter: opts.tokenCounter,
tokenLimit: opts.tokenLimit,
contextThresholdPercent: 0.9,
isAssistantMode: false
})
let raw = ""
const { completionResult } = await adapter.generate()
if (typeof completionResult === "string") {
raw = completionResult
} else {
await completionResult((chunk: string) => {
raw += chunk
})
}
return raw.trim()
}
export async function generateSummary(
input: SummarizeInput
): Promise<SummarizeResult> {
const { messages, loreType, topic, connection, sampling, contextConfig, promptConfig, onProgress } = input
const tokenCounter = new TokenCounters("estimate")
const tokenLimit: number = (connection as any).tokenLimit ?? (connection as any).contextSize ?? 4096
const genOpts = { connection, sampling, contextConfig, promptConfig, tokenCounter, tokenLimit }
const batches = batchMessages(messages, tokenLimit)
const totalBatches = batches.length
// ── Phase 1: Draft each batch independently ──────────────────────────────
const drafts: JsonDraft[] = []
for (let i = 0; i < batches.length; i++) {
const jsonMessages = formatMessagesAsJson(batches[i])
const promptData = buildBatchPrompt({ jsonMessages, loreType, topic })
const raw = await runGeneration(promptData, { ...genOpts, maxTokens: 1000 })
const parsed = parseSummaryOutput(raw)
const draftContent = parsed.content || raw // fall back to raw if tags missing
drafts.push({ part: i + 1, draft: draftContent })
onProgress?.({
phase: "drafting",
batch: i + 1,
totalBatches,
partial: { content: parsed.content, raw }
})
}
// ── Phase 2: Synthesize all drafts into one entry ────────────────────────
onProgress?.({
phase: "synthesizing",
batch: totalBatches,
totalBatches,
partial: {}
})
// ── Name generation helper ───────────────────────────────────────────────
async function generateName(content: string): Promise<string | undefined> {
try {
const namePrompt = buildNamePrompt({ content, loreType })
const nameRaw = await runGeneration(namePrompt, { ...genOpts, maxTokens: 30 })
const name = nameRaw.trim().replace(/['".,!?]+$/g, "")
return name.length > 0 ? name : undefined
} catch {
return undefined
}
}
// If only one batch, skip synthesis — the single draft is the final result
if (drafts.length === 1) {
const content = drafts[0].draft
const name = loreType === "world" ? await generateName(content) : undefined
return {
content,
name,
raw: drafts[0].draft,
batchCount: totalBatches
}
}
const jsonDrafts = JSON.stringify(drafts, null, 2)
const synthesisPrompt = buildSynthesisPrompt({ jsonDrafts, loreType, topic })
const synthesisRaw = await runGeneration(synthesisPrompt, { ...genOpts, maxTokens: 2000 })
const finalParsed = parseSummaryOutput(synthesisRaw)
// Fall back to joining drafts if synthesis fails to produce tags
const fallbackContent = drafts.map((d) => d.draft).join("\n\n")
const finalContent = finalParsed.content || fallbackContent
onProgress?.({
phase: "synthesizing",
batch: totalBatches,
totalBatches,
partial: { content: finalParsed.content, raw: synthesisRaw }
})
const name = loreType === "world" ? await generateName(finalContent) : undefined
return {
content: finalContent,
name,
raw: synthesisRaw,
batchCount: totalBatches
}
}

View file

@ -0,0 +1,62 @@
/**
* Parses XML-style tagged fields from LLM plain text output.
* Designed to be forgiving always returns raw output as fallback.
*/
export interface ParsedSummary {
name?: string
date?: string
content?: string
raw: string
}
function extractTag(raw: string, tag: string): string | undefined {
const regex = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, "i")
const match = raw.match(regex)
if (!match) return undefined
const trimmed = match[1].trim()
return trimmed.length > 0 ? trimmed : undefined
}
/** Ensure each bullet line ends with a sentence-terminating punctuation mark. */
function normalizeBulletPunctuation(content: string): string {
return content
.split("\n")
.map((line) => {
const trimmed = line.trim()
if (!trimmed.startsWith("•")) return trimmed
return /[.!?]$/.test(trimmed) ? trimmed : trimmed + "."
})
.join("\n")
}
export function parseSummaryOutput(raw: string): ParsedSummary {
const trimmed = raw.trim()
const rawContent = extractTag(trimmed, "content")
return {
name: extractTag(trimmed, "name"),
date: extractTag(trimmed, "date"),
content: rawContent !== undefined ? normalizeBulletPunctuation(rawContent) : undefined,
raw: trimmed
}
}
/**
* Parse a date string like "Year 412, Month 3, Day 7" into numeric parts.
* Returns nulls for any part that can't be parsed.
*/
export function parseDateString(dateStr: string): {
year: number | null
month: number | null
day: number | null
} {
const yearMatch = dateStr.match(/year\s+(\d+)/i)
const monthMatch = dateStr.match(/month\s+(\d+)/i)
const dayMatch = dateStr.match(/day\s+(\d+)/i)
return {
year: yearMatch ? parseInt(yearMatch[1], 10) : null,
month: monthMatch ? parseInt(monthMatch[1], 10) : null,
day: dayMatch ? parseInt(dayMatch[1], 10) : null
}
}

View file

@ -0,0 +1,177 @@
/**
* Summarizer prompt templates.
*
* Two-phase architecture:
* Phase 1 Batch drafting: each batch of messages is drafted independently.
* Messages are passed as a JSON array. Output is a single <content> tag.
* Phase 2 Synthesis: all drafts are passed as a JSON array and merged into
* one final entry. Output is a single <content> tag.
*
* Output format: bullet points. No titles, headers, or section labels.
*/
export interface SummaryPrompt {
systemPrompt: string
userPrompt: string
}
export interface JsonMessage {
speaker: string
text: string
}
export interface JsonDraft {
part: number
draft: string
}
/** Format messages as a JSON array for batch prompts. */
export function formatMessagesAsJson(
messages: { senderName: string; content: string }[]
): string {
const json: JsonMessage[] = messages.map((m) => ({
speaker: m.senderName,
text: m.content.trim()
}))
return JSON.stringify(json, null, 2)
}
// ── Phase 1: Batch draft prompts ─────────────────────────────────────────────
export function buildBatchPrompt(opts: {
jsonMessages: string
loreType: "world" | "history"
topic?: string
}): SummaryPrompt {
const { jsonMessages, loreType, topic } = opts
const topicLine = topic?.trim()
? `Focus specifically on: "${topic.trim()}"\n\n`
: ""
if (loreType === "history") {
return {
systemPrompt:
"You are a chronicler recording key events from a roleplay exchange. Your records are concise bullet points that capture what changed, why it matters, and how it affected the people involved. You write only what is directly shown — no invention, no embellishment.",
userPrompt: `The following JSON array contains a portion of a roleplay exchange. Record the key events as bullet points.
${topicLine}Rules:
- One bullet point per meaningful event, action, or change.
- Each bullet must convey: what happened, who was involved, and why it matters or what changed.
- Include emotions and reactions when they are significant.
- Write in past tense.
- Do NOT include titles, headers, section labels, or any text outside the <content> tag.
- Do not invent details not present in the messages.
Messages:
${jsonMessages}
Output ONLY in this exact format no other text before or after:
<content>
[Key event or change]
[Key event or change]
</content>`
}
}
return {
systemPrompt:
"You are an archivist recording world-building facts from a roleplay exchange. Your records are concise bullet points that capture facts, changes, and discoveries about the setting. You write only what is directly shown — no invention, no embellishment.",
userPrompt: `The following JSON array contains a portion of a roleplay exchange. Extract world lore as bullet points.
${topicLine}Rules:
- One bullet point per meaningful fact, discovery, or change about the world, factions, locations, or events.
- Focus on details that matter going forward what changed, what was revealed, what has consequences.
- Write in past tense.
- Do NOT include titles, headers, section labels, or any text outside the <content> tag.
- Do not invent details not present in the messages.
Messages:
${jsonMessages}
Output ONLY in this exact format no other text before or after:
<content>
[World lore fact or change]
[World lore fact or change]
</content>`
}
}
// ── Name generation prompt ───────────────────────────────────────────────────
export function buildNamePrompt(opts: {
content: string
loreType: "world" | "history"
}): SummaryPrompt {
const instruction =
opts.loreType === "history"
? "You generate short titles for historical chronicle entries. The title should capture the key event or turning point."
: "You generate short titles for world lore entries. The title should describe the subject of the entry."
return {
systemPrompt: instruction,
userPrompt: `Generate a short title (37 words) for the following entry. Output ONLY the title — no punctuation at the end, no quotes, no other text.
${opts.content.slice(0, 800)}
Title:`
}
}
// ── Phase 2: Synthesis prompt ────────────────────────────────────────────────
export function buildSynthesisPrompt(opts: {
jsonDrafts: string
loreType: "world" | "history"
topic?: string
}): SummaryPrompt {
const { jsonDrafts, loreType, topic } = opts
const topicLine = topic?.trim()
? `Focus specifically on: "${topic.trim()}"\n\n`
: ""
if (loreType === "history") {
return {
systemPrompt:
"You are a master chronicler. Given draft bullet points covering a roleplay exchange in chronological order, you merge them into a single clean bullet-point record. You write only what the drafts contain — no invention, no embellishment.",
userPrompt: `The following JSON array contains draft bullet-point records covering a roleplay exchange in order. Merge them into one clean, chronological bullet-point list.
${topicLine}Rules:
- Preserve chronological order.
- If multiple bullets describe the same event or moment, merge them into one bullet that captures all the detail.
- If a bullet from a later part restates something already covered, drop the repeat and keep only the richer version.
- Each bullet must convey what happened, who was involved, and why it matters or what changed.
- Include significant emotions and reactions.
- Write in past tense.
- Do NOT include titles, headers, section labels, or any text outside the <content> tag.
- Do not invent details not present in the drafts.
Drafts:
${jsonDrafts}
Output ONLY in this exact format no other text before or after:
<content>
[Key event or change]
[Key event or change]
</content>`
}
}
return {
systemPrompt:
"You are a master archivist. Given draft bullet points covering a roleplay exchange, you merge them into a single clean world lore entry. You write only what the drafts contain — no invention, no embellishment.",
userPrompt: `The following JSON array contains draft world lore bullet points from a roleplay exchange. Merge them into one clean, organized bullet-point entry.
${topicLine}Rules:
- If multiple bullets describe the same fact or location, merge them into one bullet that captures all the detail.
- If a bullet from a later part restates something already covered, drop the repeat and keep only the richer version.
- Focus on details that matter going forward what changed, what was revealed, what has consequences.
- Write in past tense.
- Do NOT include titles, headers, section labels, or any text outside the <content> tag.
- Do not invent details not present in the drafts.
Drafts:
${jsonDrafts}
Output ONLY in this exact format no other text before or after:
<content>
[World lore fact or change]
[World lore fact or change]
</content>`
}
}

View file

@ -1,6 +1,7 @@
export class ChatTypes {
static readonly ROLEPLAY = "roleplay"
static readonly ASSISTANT = "assistant"
static readonly SUMMARIZE = "summarize"
static readonly ALL = [ChatTypes.ROLEPLAY, ChatTypes.ASSISTANT] as const

View file

@ -524,6 +524,43 @@ declare global {
title: string
}
}
namespace SetLorebook {
interface Params {
chatId: number
lorebookId: number | null
}
interface Response {
chat: SelectChat
}
}
namespace Summarize {
interface Params {
chatId: number
messageIds: number[] | 'all'
loreType: 'world' | 'history'
topic?: string
}
interface Progress {
phase: 'drafting' | 'synthesizing'
batch: number
totalBatches: number
partial: {
content?: string
raw?: string
}
}
interface Response {
content: string
name?: string
raw: string
lorebookId: number
batchCount: number
}
interface ErrorResponse {
reason: 'no_lorebook' | 'no_connection' | 'generation_failed'
error: string
}
}
}
// Chat Messages namespace

View file

@ -5,6 +5,7 @@
import * as skio from "sveltekit-io"
import * as Icons from "@lucide/svelte"
import MessageComposer from "$lib/client/components/chatMessages/MessageComposer.svelte"
import MessageControls from "$lib/client/components/chatMessages/MessageControls.svelte"
import ChatContainer from "$lib/client/components/chatMessages/ChatContainer.svelte"
import ChatMessage from "$lib/client/components/chatMessages/ChatMessage.svelte"
import NextCharacterBlock from "$lib/client/components/chatMessages/NextCharacterBlock.svelte"
@ -15,6 +16,7 @@
import Avatar from "$lib/client/components/Avatar.svelte"
import PersonaSelectModal from "$lib/client/components/modals/PersonaSelectModal.svelte"
import BranchChatModal from "$lib/client/components/modals/BranchChatModal.svelte"
import SummarizeLoreModal from "$lib/client/components/modals/SummarizeLoreModal.svelte"
import { toaster } from "$lib/client/utils/toaster"
let chat: Sockets.Chats.Get.Response["chat"] | undefined = $state()
@ -47,6 +49,12 @@
let showAddPersonaModal = $state(false)
let showBranchChatModal = $state(false)
let branchFromMessage: SelectChatMessage | undefined = $state()
// Summarization mode
let isSummarizationMode = $state(false)
let selectedMessageIds = $state(new Set<number>())
let showSummarizeModal = $state(false)
let summarizeLoreType = $state<"world" | "history">("world")
let chatResponseOrder: Sockets.Chats.GetResponseOrder.Response | undefined =
$state()
let availablePersonas: Sockets.Personas.List.Response["personaList"] =
@ -564,6 +572,58 @@
}
socket.emit("chatMessages:cancel", { id: msg.id, chatId })
}
// ── Summarization mode ────────────────────────────────────────
function enterSummarizationMode(msg: SelectChatMessage) {
openMobileMsgControls = undefined
isSummarizationMode = true
selectedMessageIds = new Set([msg.id])
}
function exitSummarizationMode() {
isSummarizationMode = false
selectedMessageIds = new Set()
}
function toggleSummarizationMessage(id: number) {
const next = new Set(selectedMessageIds)
next.has(id) ? next.delete(id) : next.add(id)
selectedMessageIds = next
}
function selectAllAbove(msgIndex: number) {
const msgs = chat!.chatMessages
const next = new Set(selectedMessageIds)
next.add(msgs[msgIndex].id)
for (let i = msgIndex - 1; i >= 0; i--) {
if (next.has(msgs[i].id)) break
next.add(msgs[i].id)
}
selectedMessageIds = next
}
function selectAllBelow(msgIndex: number) {
const msgs = chat!.chatMessages
const next = new Set(selectedMessageIds)
next.add(msgs[msgIndex].id)
for (let i = msgIndex + 1; i < msgs.length; i++) {
if (next.has(msgs[i].id)) break
next.add(msgs[i].id)
}
selectedMessageIds = next
}
function openSummarizeModal(loreType: "world" | "history") {
summarizeLoreType = loreType
showSummarizeModal = true
}
function handleLorebookSet(newLorebookId: number) {
if (chat) {
chat = { ...chat, lorebookId: newLorebookId } as typeof chat
}
}
// ─────────────────────────────────────────────────────────────
function handleBranchMessage(e: Event, msg: SelectChatMessage) {
e.stopPropagation()
openMobileMsgControls = undefined
@ -999,6 +1059,9 @@
onSaveEditMessage={handleSaveEditMessage}
bind:openMobileMsgControls
{lastPersonaMessage}
isSummarizationMode={isSummarizationMode}
isSelected={selectedMessageIds.has(props.msg.id)}
onStartSummarization={!isSummarizationMode ? enterSummarizationMode : undefined}
>
{#snippet GeneratingAnimationComponent()}
{@const character = props.getMessageCharacter(props.msg)}
@ -1006,40 +1069,146 @@
text={`${character?.nickname || character?.name || "User"} is typing`}
/>
{/snippet}
{#snippet messageControls(msg)}
{#if isSummarizationMode}
<div class="flex gap-2" role="group" aria-label="Selection controls">
<button
class="btn btn-sm {selectedMessageIds.has(msg.id)
? 'preset-filled-secondary-500'
: 'preset-tonal-surface'}"
title={selectedMessageIds.has(msg.id) ? 'Deselect message' : 'Select message'}
onclick={() => toggleSummarizationMessage(msg.id)}
>
{#if selectedMessageIds.has(msg.id)}
<Icons.CheckSquare size={16} />
{:else}
<Icons.Square size={16} />
{/if}
<span class="lg:hidden">
{selectedMessageIds.has(msg.id) ? 'Deselect' : 'Select'}
</span>
</button>
<button
class="btn btn-sm preset-tonal-surface"
title="Select all above up to nearest selected"
onclick={() => selectAllAbove(props.index)}
>
<Icons.ChevronsUp size={16} />
<span class="lg:hidden">Select All Above</span>
</button>
<button
class="btn btn-sm preset-tonal-surface"
title="Select all below up to nearest selected"
onclick={() => selectAllBelow(props.index)}
>
<Icons.ChevronsDown size={16} />
<span class="lg:hidden">Select All Below</span>
</button>
</div>
{:else}
<MessageControls
{msg}
isLastMessage={props.isLastMessage}
canRegenerateLastMessage={props.canRegenerateLastMessage}
editChatMessage={props.editChatMessage}
hasGeneratingMessage={props.hasGeneratingMessage}
onEditMessage={props.onEditMessage}
onHideMessage={props.onHideMessage}
onDeleteMessage={props.onDeleteMessage}
onRegenerateMessage={props.onRegenerateMessage}
onContinueMessage={props.onContinueMessage}
onAbortMessage={props.onAbortMessage}
onBranchMessage={props.onBranchMessage}
onStartSummarization={enterSummarizationMode}
/>
{/if}
{/snippet}
</ChatMessage>
{/snippet}
{#snippet ComposerComponent()}
<ChatComposer
bind:newMessage
onSend={handleSend}
{draftCompiledPrompt}
{currentUserPersona}
{chat}
{lastMessage}
{editChatMessage}
{isGuest}
{showAddPersonaCTA}
onAddPersonaClick={() => {
showAddPersonaModal = true
}}
onAbortLastMessage={handleAbortLastMessage}
extraTabs={isGuest
? []
: [
{
value: "extraControls",
title: "Extra Controls",
control: extraControlsButton,
content: extraControlsContent
},
{
value: "statistics",
title: "Statistics",
control: statisticsButton,
content: statisticsContent
}
]}
/>
{#if isSummarizationMode}
<div class="preset-tonal-secondary flex flex-wrap items-center gap-2 p-3 lg:rounded-t-lg">
<span class="text-sm font-semibold">
{selectedMessageIds.size}
{selectedMessageIds.size === 1 ? 'message' : 'messages'} selected
</span>
<div class="flex gap-2">
<button
class="btn btn-sm preset-tonal-surface"
onclick={() => {
selectedMessageIds = new Set(chat!.chatMessages.map((m) => m.id))
}}
>
<Icons.CheckSquare size={16} />
Select All
</button>
<button
class="btn btn-sm preset-tonal-surface"
onclick={() => (selectedMessageIds = new Set())}
>
<Icons.Square size={16} />
Select None
</button>
</div>
<div class="ml-auto flex flex-wrap gap-2">
<button
class="btn btn-sm preset-filled-surface-500"
onclick={exitSummarizationMode}
>
<Icons.X size={16} />
Cancel
</button>
<button
class="btn btn-sm preset-filled-primary-500"
disabled={selectedMessageIds.size === 0}
onclick={() => openSummarizeModal('world')}
>
<Icons.Globe size={16} />
World Lore
</button>
<button
class="btn btn-sm preset-filled-secondary-500"
disabled={selectedMessageIds.size === 0}
onclick={() => openSummarizeModal('history')}
>
<Icons.Scroll size={16} />
History Entry
</button>
</div>
</div>
{:else}
<ChatComposer
bind:newMessage
onSend={handleSend}
{draftCompiledPrompt}
{currentUserPersona}
{chat}
{lastMessage}
{editChatMessage}
{isGuest}
{showAddPersonaCTA}
onAddPersonaClick={() => {
showAddPersonaModal = true
}}
onAbortLastMessage={handleAbortLastMessage}
extraTabs={isGuest
? []
: [
{
value: "extraControls",
title: "Extra Controls",
control: extraControlsButton,
content: extraControlsContent
},
{
value: "statistics",
title: "Statistics",
control: statisticsButton,
content: statisticsContent
}
]}
/>
{/if}
{/snippet}
{#snippet NextCharacterComponent()}
{#if shouldShowNextCharacterBlock}
@ -1054,6 +1223,17 @@
</ChatContainer>
</div>
<SummarizeLoreModal
bind:open={showSummarizeModal}
onOpenChange={(e) => (showSummarizeModal = e.open)}
{chatId}
lorebookId={chat?.lorebookId ?? null}
selectedMessageIds={[...selectedMessageIds]}
initialLoreType={summarizeLoreType}
onSaved={exitSummarizationMode}
onLorebookSet={handleLorebookSet}
/>
<Modal
open={showDeleteMessageModal}
onOpenChange={onOpenMessageDeleteChange}